13 Commits

Author SHA1 Message Date
Claude
d43f78b161 av: clone broadcastbeat sources + AV Beat rebrand (Phase 1)
- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch)
- Domain: broadcastbeat.com -> avbeat.com
- Brand: Broadcast Beat -> AV Beat
- Slug: broadcastbeat -> avbeat (package, identifiers)
- Schema fallback: bb -> av (env var name unchanged)
- Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg
- All BB/RMP logo references repointed

Outstanding before public DNS swap:
  - Supabase av schema bootstrap (mirror of bb tables)
  - WordPress archive import (avbeat-com -> av.articles)
  - Coolify env vars
  - dev-avbeat.onsethost.com staging deploy + visual review
2026-06-02 15:32:56 +00:00
Ryan Salazar
dd93a74bdf Brand cleanup: swap remaining user-facing BB strings + accent blue → AV orange
Hit pages we didn't get the first pass: rss, gear, show-coverage,
articles/[slug], contributor, manufacturers, login, client-login, terms,
dashboard/show-calendar, and team-page derive helpers (domain + comment).
2026-06-01 15:39:09 +00:00
Ryan Salazar
c9b85b79b7 Dockerfile: npm install --include=dev to keep autoprefixer at build time
Same fix that landed for RMP cold-build on 2026-05-30. Coolify passes
NODE_ENV=production as a buildtime env (set on the app for runtime), which
causes npm to skip devDependencies and Next.js's PostCSS pipeline can't
load autoprefixer.
2026-06-01 15:38:18 +00:00
Ryan Salazar
d67a2bd48d AV Beat rebrand: theme swap (orange #E67E22 / near-black), logo, schema default → avb
- public/assets/images/logo.png + public/brand/logo*.png replaced with the 1320x310 AV Beat logo
- public/assets/logos/avbeat.png added as the canonical source
- tailwind.config.js + src/styles/tailwind.css + src/styles/redesign-tokens.css palette swapped (primary #0F0E0E, secondary #1D1A1A, accent #E67E22 orange, foreground #F0EBE6 warm white)
- Layout / robots / sitemap / home-page / about / contact / press-kit / team / global-error metadata switched to AV Beat (Where AV Meets IT)
- Bulk replaced 'Broadcast Beat'/'BroadcastBeat.com'/'broadcastbeat.com' across user-facing news/forum/marketplace/advertise/search/newsletter pages
- src/lib/supabase/{admin,server,client} now default to schema 'avb' (was 'bb') — overridable via NEXT_PUBLIC_SUPABASE_SCHEMA
- package.json renamed to 'avbeat'
- Single d60701 reference in about/team swapped to E67E22

Design-only replica — no article content migrated.
2026-06-01 15:30:16 +00:00
claude-code
6a48473905 news filter row: add items-center so sort dropdown aligns horizontally with date-range box 2026-06-01 02:03:57 +00:00
Claude Code
6b9251ca9b Modernize /about/team layout + add 9 SD-generated persona portraits + dot-form email helper
Automated persistence of 2026-05-30 session changes. See infrastructure_audit/ for context.
2026-05-30 13:57:13 +00:00
Ryan Salazar
e6095dd803 fix(article): remove duplicate excerpt rendering above the article body
Excerpt was rendered both as a standalone <p> at the top of the article
header AND as the first paragraph of the body content (because PR
submissions paste the lede into both fields). Drop the header copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:55:15 +00:00
Ryan Salazar
445a11a1ee fix(img-proxy): retry transient 5xx, no-store on failure, dynamic route
Bug: a single transient Supabase storage 5xx was being permanently cached
because the route was force-static + force-cache, and the wrapping page
has s-maxage=31536000. Result: real image (still in bucket) showed as
broken on production article pages.

Fix:
- dynamic = force-dynamic, revalidate = 0 (no build-time pinning)
- fetch with no-store + 3-try retry with backoff for 5xx
- failures emit cache-control: no-store so they don't pin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:34:24 +00:00
Ryan Salazar
891f2dd341 auth: forgot/reset password + magic link + Google/MS/LinkedIn buttons
Forgot-password flow at /forum/forgot-password (Supabase resetPassword)
plus /forum/reset-password that catches the recovery session and lets
the user set a new password. 'Forgot password?' link added to the login
form.

SocialAuthButtons component drops onto both login + register pages with
three OAuth providers (Google, Microsoft / Azure, LinkedIn — Apple
skipped per Ryan, can add later when iOS app ships) plus a
no-password magic-link option that works zero-config via Supabase OTP.

Register page now detects already-known subscribers via the new
/api/auth/lookup-email endpoint and sends a magic-link login instead of
forcing them to pick a password. They can set a password later from
their account if they want.

Provider-side setup required: register OAuth apps in Google Cloud
Console / Microsoft Entra / LinkedIn Developers, paste client IDs +
secrets into Supabase dashboard → Authentication → Providers, set
redirect URL to https://broadcastbeat.com/auth/callback. Code is wired
to receive them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 19:22:14 +00:00
Ryan Salazar
0ede78fd35 articles: discussion threads + Related Forum Posts sidebar
ArticleComments component renders under every news + articles detail
page. Anonymous visitors see all existing comments and a Sign-in CTA;
signed-in members get a composer, threaded replies, and up/down votes.
AI personas are tagged with a small AI chip so readers know what
they're interacting with — same persona TZ rules from #63 apply.

Schema extensions on bb.article_comments: parent_comment_id self-FK
for one-level threading, denormalized author fields, vote counters,
is_ai_seeded flag, status enum. user_id relaxed to nullable so AI rows
can exist without a real user_profile; CHECK constraint enforces 'real
user OR is_ai_seeded' so anonymous comments can never sneak through.
forum_votes.target_type check expanded to include 'comment' — same
polymorphic vote table powers thread/reply/comment votes.

New Related Forum Posts sidebar on both /articles/[slug] and
/news/[slug]. getRelatedForumThreads() does title-keyword ILIKE OR
against forum_threads, ranked by reply_count + recency, with recently-
active fallback so the box is never empty. 6 threads per article.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:25:19 +00:00
Ryan Salazar
39e9777e0a forum: image attachments on threads + replies, AVIF picture render
New ImageAttachButton drops onto any composer textarea: file picker
upload → /api/upload/image (the universal pipeline) → markdown
![alt](primaryUrl) appended to the body. New thread + reply composers
both gain the button with a 'Auto-optimized, never > 20 MB' caption.

RichBody is the read-path companion: parses body markdown for image
syntax and renders each as a <picture> element with the AVIF source
first (derived from the .webp sibling on our bb-media bucket), then the
WebP, then the original-format fallback img. Prose around images still
runs through LinkifyPlainText so company auto-links still work.

Forum users now have a way to include screenshots, gear photos, NAB
booth shots, etc. in their posts — every image flows through the same
optimization that gives <10% of original byte weight at visually
identical quality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:08:59 +00:00
Ryan Salazar
e24d78cd15 images: unified server-side optimization pipeline + /api/upload/image
Single shared sharp-based pipeline (src/lib/images/optimize.ts) used by
every upload surface going forward — PR submissions, contributor
articles, featured-story admin, forum composer attachments, avatars.
Resizes to 4096px longest side max, emits AVIF + WebP + format-preserving
fallback (JPEG/PNG) + 320px thumbnail, all at perceptual-lossless q=82.

POST /api/upload/image is the universal endpoint: auth-gated (signed-in
Supabase user required), 20MB input cap, MIME-validated, processes the
buffer, uploads every derivative to the bb-media bucket under
<user_id>/<yyyy>/<mm>/<random>.{webp,avif,jpg,png} with year-long
immutable caching. Returns dimensions + all derivative URLs + a
primaryUrl convenience field for simple <img src=> embedding.

Bucket reconfigured: file_size_limit bumped 10MB → 25MB and image/avif
added to allowed_mime_types so AVIF derivatives accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:43:04 +00:00
Ryan Salazar
976c979e54 forum register/login: rebrand as Broadcast Beat membership
Page heading 'Create a Forum Account' read like a single-purpose signup
buried under the forum, which it no longer is. Refreshed to 'Join
Broadcast Beat' with a left-column benefits rail (forum, newsletter
preferences, saved articles, reply notifications) so the user understands
the same account covers everything we'll add over time. Sign-in page
gets the matching 'Sign in to Broadcast Beat' header with the same dark
gradient + branded copy.

PR-firm distinction is signposted explicitly below the rail: contributors
who want to publish press releases need a separate application — they
shouldn't accidentally sign up as a regular member and expect to post
news. Submission-receive auth gate already enforces this; the message
just makes the path visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:39:02 +00:00
157 changed files with 6208 additions and 4315 deletions

View File

@@ -1,4 +1,4 @@
# Pending — broadcastbeat.com tonight's deferred work
# Pending — avbeat.com tonight's deferred work
## Featured-image backfill (P0-1, deferred)
@@ -12,7 +12,7 @@ in Supabase storage) is **NOT done**. The smoke-test attempt
`/tmp/bb-image-backfill.py` ran on coolify01:
- Matrox+Amagi smoke article (`matrox-video-and-amagi-collaborate-...`):
Wayback returned no archived snapshot of `https://www.broadcastbeat.com/<slug>/`,
Wayback returned no archived snapshot of `https://www.avbeat.com/<slug>/`,
so the og:image extraction path didn't apply. Many of the broken-image
articles are recent NAB 2026 posts that have never been crawled by
Wayback.
@@ -22,7 +22,7 @@ in Supabase storage) is **NOT done**. The smoke-test attempt
**What needs to happen next session:**
1. For each broken article, try multiple sources in order:
- Wayback Machine snapshot of `broadcastbeat.com/<slug>/` (skip if 404).
- Wayback Machine snapshot of `avbeat.com/<slug>/` (skip if 404).
- Manufacturer press-kit page based on title keywords (Matrox, Amagi,
Telestream, Calrec, AJA, etc. — keep a per-vendor mapping of press
kit URLs).

View File

@@ -29,6 +29,6 @@ export const imageHosts = [
},
{
protocol: 'https',
hostname: 'www.broadcastbeat.com',
hostname: 'www.avbeat.com',
},
];

View File

@@ -70,7 +70,7 @@ const nextConfig = {
destination: 'https://relevantmediaproperties.com/broadcast-beat---media-kit',
permanent: true,
},
// WordPress admin redirects for broadcastbeat.com (301 permanent)
// WordPress admin redirects for avbeat.com (301 permanent)
{
source: '/wp-admin',
destination: '/admin',

View File

@@ -1,5 +1,5 @@
{
"name": "broadcastbeat",
"name": "avbeat",
"version": "0.1.0",
"private": true,
"rocketCritical": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 96" width="360" height="96">
<defs>
<linearGradient id="av-grad" x1="0" x2="1">
<stop offset="0%" stop-color="#FFB300"/>
<stop offset="55%" stop-color="#FF6B00"/>
<stop offset="100%" stop-color="#D60701"/>
</linearGradient>
</defs>
<g font-family="Plus Jakarta Sans, sans-serif" font-weight="800">
<text x="0" y="44" font-size="40" fill="url(#av-grad)" letter-spacing="2">AVB</text>
<text x="0" y="82" font-size="20" fill="#f4f4f6" letter-spacing="2.5" opacity="0.85">AV Beat</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 594 B

View File

@@ -2,13 +2,13 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer";
import SidebarAdStack from "@/components/SidebarAdStack";
export const metadata = { title: "Contact — Broadcast Beat" };
export const metadata = { title: "Contact — AV Beat" };
const CONTACTS = [
{
title: "Editorial",
body: (
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@broadcastbeat.com">editor@broadcastbeat.com</a>
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
),
hint: "Story tips, corrections, interview requests.",
},
@@ -26,7 +26,7 @@ const CONTACTS = [
title: "Advertising",
body: (
<>
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@broadcastbeat.com">advertising@broadcastbeat.com</a> see the{" "}
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@avbeat.com">advertising@avbeat.com</a> see the{" "}
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">media kit</a>.
</>
),
@@ -53,7 +53,7 @@ export default function ContactPage() {
<main>
<header className="mb-8 border-b border-[#252525] pb-6">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
Broadcast Beat
AV Beat
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1>
<p className="mt-3 text-[#9ca3af] text-base">

View File

@@ -3,8 +3,8 @@ import Footer from "@/components/Footer";
import SidebarAdStack from "@/components/SidebarAdStack";
export const metadata = {
title: "About Broadcast Beat",
description: "Broadcast Beat is an independent industry publication covering broadcast, production, and post-production technology.",
title: "About AV Beat",
description: "AV Beat is an independent industry publication covering broadcast, production, and post-production technology.",
};
export default function AboutPage() {
@@ -16,7 +16,7 @@ export default function AboutPage() {
<main>
<header className="mb-8 border-b border-[#252525] pb-6">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
Broadcast Beat
AV Beat
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">About</h1>
<p className="mt-3 text-[#9ca3af] text-base max-w-2xl">
@@ -32,7 +32,7 @@ export default function AboutPage() {
<section className="space-y-6 text-[#d1d5db] text-[15px] leading-relaxed">
<Block title="What we cover">
Broadcast Beat reports on the equipment, services, and people
AV Beat reports on the equipment, services, and people
that move the professional video industry forward. We follow
product launches, executive moves, acquisitions, and the live
production / IP / cloud workflows reshaping how content gets
@@ -57,7 +57,7 @@ export default function AboutPage() {
</Block>
<Block title="Get involved">
For editorial pitches: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@broadcastbeat.com">editor@broadcastbeat.com</a>.
For editorial pitches: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>.
{" "}For advertising: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">see the media kit</a>.
{" "}For our team: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/team">meet the editorial team</a>.
</Block>

View File

@@ -1,7 +1,7 @@
import Header from "@/components/Header";
import Footer from "@/components/Footer";
export const metadata = { title: "Press kit — Broadcast Beat" };
export const metadata = { title: "Press kit — AV Beat" };
export default function PressKitPage() {
return (
@@ -13,15 +13,15 @@ export default function PressKitPage() {
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Logos</h2>
<ul className="space-y-1">
<li><a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/assets/images/logo.png" download>Broadcast Beat logo (PNG)</a></li>
<li><a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/assets/images/logo.png" download>AV Beat logo (PNG)</a></li>
</ul>
</section>
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Boilerplate</h2>
<p className="text-[#d1d5db] leading-relaxed">
Broadcast Beat is an independent trade publication covering broadcast,
AV Beat is an independent trade publication covering broadcast,
live production, streaming, and post-production technology. Founded
to bring engineering-rigor reporting to industry coverage, Broadcast Beat
to bring engineering-rigor reporting to industry coverage, AV Beat
publishes daily on the equipment, services, and people that move
professional video forward.
</p>

View File

@@ -8,8 +8,8 @@ export const dynamic = "force-dynamic";
export const revalidate = 600;
export const metadata = {
title: "Our team — Broadcast Beat",
description: "Meet the Broadcast Beat team — editorial staff plus accounts and support, covering broadcast, streaming, and post-production.",
title: "Our team — AV Beat",
description: "Meet the AV Beat team — editorial staff plus accounts and support, covering broadcast, streaming, and post-production.",
};
interface Persona {
@@ -27,6 +27,7 @@ interface OpsPersona {
email: string | null;
phone: string | null;
extension: string | null;
avatar_url: string | null;
}
export default async function TeamPage() {
@@ -60,9 +61,9 @@ export default async function TeamPage() {
);
const { data: opsData } = await sbOps
.from("personas")
.select("id, display_name, role, email, phone, extension")
.select("id, display_name, role, email, phone, extension, avatar_url")
.eq("active", true)
.in("id", ["chloe", "riley"]);
.in("id", ["chloe", "jeff", "riley", "brian", "avery"]);
const ops = (opsData || []) as OpsPersona[];
const displayBeat = (p: Persona) =>
@@ -76,11 +77,11 @@ export default async function TeamPage() {
.map((w) => w.charAt(0).toUpperCase())
.join("");
// Derive @broadcastbeat.com email from the persona slug. Real mailboxes
// Derive @avbeat.com email from the persona slug. Real mailboxes
// are set up centrally; this is the authoring address surfaced to readers.
const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
if (!p.slug) return null;
return { user: p.slug, domain: "broadcastbeat.com" };
return { user: p.slug.replace(/-/g, "."), domain: "avbeat.com" };
};
const splitEmail = (email: string): { user: string; domain: string } | null => {
@@ -95,82 +96,152 @@ export default async function TeamPage() {
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main>
<header className="mb-8 border-b border-[#252525] pb-6">
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
Broadcast Beat
{/* Hero */}
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#d60701,transparent_60%)]" aria-hidden />
<div className="relative px-6 py-10 sm:px-10 sm:py-14">
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-[#ff5a55] mb-3">
<span className="w-6 h-px bg-[#d60701]" />
AV Beat &middot; editorial
</div>
<h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
The team behind every byline
</h1>
<p className="mt-4 text-[#9ca3af] text-base md:text-lg max-w-2xl leading-relaxed">
Beats covered by trained AI personas working under house style, plus the
business and support people who keep things moving.
</p>
</div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Editorial team</h1>
<p className="mt-3 text-[#9ca3af] text-base">
Staff journalists by beat a mix of human reporters and trained AI personas operating under house style.
</p>
</header>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
{team.map((p) => (
<li key={p.slug} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
{p.avatar_url && (
<img
src={p.avatar_url}
alt={p.name}
width={64}
height={64}
className="rounded-full border border-[#252525] flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<h2 className="font-serif text-lg font-semibold">{p.name}</h2>
{(p.beat || p.slug === "ryan-salazar") && (
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
{displayBeat(p)}
</div>
)}
{p.bio && <p className="text-sm text-[#9ca3af] mt-2 leading-relaxed">{p.bio}</p>}
{(() => {
const e = editorialEmail(p);
if (!e) return null;
return (
<p className="text-xs text-[#9ca3af] mt-2">
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
</p>
);
})()}
</div>
</li>
))}
</ul>
{/* Editorial team — three-col grid on desktop, larger portraits */}
<section className="mb-14">
<div className="flex items-baseline justify-between mb-5">
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{team.length} {team.length === 1 ? "writer" : "writers"}
</span>
</div>
{ops.length > 0 && (
<>
<header className="mt-12 mb-6 border-b border-[#252525] pb-4">
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Accounts &amp; support</h2>
<p className="mt-2 text-[#9ca3af] text-sm">
For billing, accounts receivable, vendor onboarding, and general support questions these are the people to reach.
</p>
</header>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
{ops.map((p) => (
<li key={p.id} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
<div className="w-16 h-16 rounded-full border border-[#252525] flex-shrink-0 bg-[#1a1a1a] flex items-center justify-center font-serif font-bold text-xl text-[#60a5fa]">
{initials(p.display_name)}
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{team.map((p) => {
const e = editorialEmail(p);
return (
<li
key={p.slug}
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#d60701]/50 hover:shadow-[0_0_0_1px_rgba(214,7,1,0.15)] transition-all"
>
{/* Top accent */}
<div className="absolute top-0 left-5 right-5 h-px bg-gradient-to-r from-transparent via-[#d60701]/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
<div className="flex items-start gap-4">
{p.avatar_url ? (
<a href={`/authors/${p.slug}`} className="flex-shrink-0">
<img
src={p.avatar_url}
alt={p.name}
width={96}
height={96}
className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] group-hover:border-[#d60701]/40 transition-colors object-cover"
/>
</a>
) : (
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-serif font-bold text-2xl text-[#6c7686] flex-shrink-0">
{initials(p.name)}
</div>
)}
<div className="flex-1 min-w-0">
<a href={`/authors/${p.slug}`} className="block hover:underline">
<h3 className="font-serif text-xl font-bold leading-tight">{p.name}</h3>
</a>
{(p.beat || p.slug === "ryan-salazar") && (
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#d60701]/10 border border-[#d60701]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#ff6b66]">
{displayBeat(p)}
</div>
)}
</div>
</div>
{p.bio && (
<p className="text-sm text-[#aab1bd] mt-4 leading-relaxed line-clamp-4">
{p.bio}
</p>
)}
{e && (
<div className="mt-4 pt-4 border-t border-[#1a1f2a] flex items-center justify-between text-xs">
<ObfuscatedEmail
user={e.user}
domain={e.domain}
className="text-[#aab1bd] hover:text-[#ff6b66] font-mono"
/>
<a
href={`/authors/${p.slug}`}
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#ff6b66]"
>
View
</a>
</div>
)}
</li>
);
})}
</ul>
</section>
{/* Operations — denser layout */}
{ops.length > 0 && (
<section>
<div className="flex items-baseline justify-between mb-5">
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">
Accounts &amp; support
</h2>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
</span>
</div>
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
For billing, accounts receivable, vendor onboarding, sales, and
general support questions these are the people to reach.
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{ops.map((p) => (
<li
key={p.id}
className="rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 flex gap-4 hover:border-[#d60701]/40 transition-colors"
>
{p.avatar_url ? (
<img
src={p.avatar_url}
alt={p.display_name}
width={80}
height={80}
className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 object-cover"
/>
) : (
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-serif font-bold text-xl text-[#6c7686]">
{initials(p.display_name)}
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="font-serif text-lg font-semibold">{p.display_name}</h3>
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#1a2535] border border-[#3b82f6]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]">
{p.role}
</div>
<div className="text-xs text-[#9ca3af] mt-2 space-y-0.5">
<div className="text-xs text-[#aab1bd] mt-3 space-y-1">
{p.email && (() => {
const e = splitEmail(p.email);
if (!e) return null;
return (
<div>
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
<div className="font-mono">
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#ff6b66]" />
</div>
);
})()}
{p.phone && (
<div>
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#60a5fa]">
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#ff6b66] font-mono">
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
</a>
</div>
@@ -180,7 +251,7 @@ export default async function TeamPage() {
</li>
))}
</ul>
</>
</section>
)}
</main>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter, useSearchParams } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
const STATUSES = ['active','completed','cancelled'];
const SCHEDULES = ['single','monthly','quarterly'];

View File

@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
function fmt(cents: number) { return '$' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 }); }
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
export default function ReportsPage() {
const { user, loading } = useAuth();

View File

@@ -98,7 +98,7 @@ export default function AdOpsIOsPage() {
</select>
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Sites</option>
{['broadcastbeat','avbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
{['avbeat','avbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>

View File

@@ -75,7 +75,7 @@ export default function AdOpsDashboardPage() {
<div className="flex gap-2 flex-wrap">
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Sites</option>
{['broadcastbeat','avbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
{['avbeat','avbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
</select>
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
<option value="">All Statuses</option>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
const PRODUCT_TYPES = [
'display_banner_300x250','display_banner_728x90','display_banner_300x600',
'display_banner_970x250','newsletter_banner_600x100','dedicated_email_blast',

View File

@@ -9,7 +9,7 @@ interface BannedTerm {
id: string;
term: string;
ban_level: 'soft' | 'hard';
site_scope: 'broadcastbeat' | 'avbeat' | 'both';
site_scope: 'avbeat' | 'avbeat' | 'both';
is_active: boolean;
notes: string | null;
created_at: string;
@@ -19,7 +19,7 @@ interface BannedTerm {
interface FormState {
term: string;
ban_level: 'soft' | 'hard';
site_scope: 'broadcastbeat' | 'avbeat' | 'both';
site_scope: 'avbeat' | 'avbeat' | 'both';
notes: string;
}
@@ -255,7 +255,7 @@ export default function BannedTermsPage() {
<select value={form.site_scope} onChange={e => setForm(f => ({ ...f, site_scope: e.target.value as any }))}
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]">
<option value="both">Both Sites</option>
<option value="broadcastbeat">Broadcast Beat only</option>
<option value="avbeat">AV Beat only</option>
<option value="avbeat">AV Beat only</option>
</select>
</div>
@@ -291,7 +291,7 @@ export default function BannedTermsPage() {
<select value={filterSiteScope} onChange={e => setFilterSiteScope(e.target.value)}
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#3b82f6]">
<option value="">All Sites</option>
<option value="broadcastbeat">Broadcast Beat</option>
<option value="avbeat">AV Beat</option>
<option value="avbeat">AV Beat</option>
<option value="both">Both</option>
</select>
@@ -336,7 +336,7 @@ export default function BannedTermsPage() {
</span>
</td>
<td className="px-4 py-3">
<span className="text-xs text-[#888] font-body capitalize">{term.site_scope === 'both' ? 'Both Sites' : term.site_scope === 'broadcastbeat' ? 'Broadcast Beat' : 'AV Beat'}</span>
<span className="text-xs text-[#888] font-body capitalize">{term.site_scope === 'both' ? 'Both Sites' : term.site_scope === 'avbeat' ? 'AV Beat' : 'AV Beat'}</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full font-body ${term.is_active ? 'bg-green-500/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>
@@ -379,7 +379,7 @@ export default function BannedTermsPage() {
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 font-bold">Hard Ban</span>
</div>
<p className="text-xs text-[#555] font-body">Submission is blocked. Post saved as draft automatically. Contributor sees only: "This post could not be submitted. Please contact the editorial team at editor@broadcastbeat.com for assistance." Admin receives email notification.</p>
<p className="text-xs text-[#555] font-body">Submission is blocked. Post saved as draft automatically. Contributor sees only: "This post could not be submitted. Please contact the editorial team at editor@avbeat.com for assistance." Admin receives email notification.</p>
</div>
</div>
</div>

View File

@@ -68,7 +68,7 @@ type TabType = 'queue' | 'companies' | 'crawl-log' | 'settings';
type QueueStatus = 'generated' | 'pending' | 'approved' | 'published' | 'rejected';
const SITE_CONFIG = {
1: { name: 'Broadcast Beat', badge: 'BB', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
1: { name: 'AV Beat', badge: 'BB', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
2: { name: 'AV Beat', badge: 'AV', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]' },
};
@@ -275,7 +275,7 @@ export default function CompanyTrackerPage() {
<span className="text-[#888] text-sm font-body">Company Tracker</span>
</div>
<h1 className="text-2xl font-display font-bold text-white">Company Story Engine</h1>
<p className="text-[#555] text-sm font-body mt-1">Automated story generation from company mentions across Broadcast Beat & AV Beat</p>
<p className="text-[#555] text-sm font-body mt-1">Automated story generation from company mentions across AV Beat & AV Beat</p>
</div>
<div className="flex items-center gap-3">
{/* Auto-Pilot toggle */}
@@ -344,7 +344,7 @@ export default function CompanyTrackerPage() {
<select value={queueSiteFilter} onChange={e => setQueueSiteFilter(e.target.value)}
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-1.5 text-[#555] text-xs font-body focus:outline-none">
<option value="">All Sites</option>
<option value="1">Broadcast Beat</option>
<option value="1">AV Beat</option>
<option value="2">AV Beat</option>
</select>
</div>
@@ -412,7 +412,7 @@ export default function CompanyTrackerPage() {
<select onChange={e => handleQueueAction(item.id, 'reassign_site', { site_id: parseInt(e.target.value) })}
defaultValue={item.site_id}
className="bg-[#111] border border-[#333] rounded px-2 py-1 text-xs text-white font-body focus:outline-none">
<option value={1}>Broadcast Beat</option>
<option value={1}>AV Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>
@@ -455,7 +455,7 @@ export default function CompanyTrackerPage() {
<select value={addCompanyForm.site_scope} onChange={e => setAddCompanyForm(f => ({ ...f, site_scope: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]">
<option value="both">Both Sites</option>
<option value="broadcastbeat">Broadcast Beat only</option>
<option value="avbeat">AV Beat only</option>
<option value="avbeat">AV Beat only</option>
</select>
</div>
@@ -494,7 +494,7 @@ export default function CompanyTrackerPage() {
<td className="px-4 py-3 text-sm font-body font-semibold text-white">{company.company_name}</td>
<td className="px-4 py-3 text-xs text-[#555] font-body">{company.company_website ? <a href={`https://${company.company_website}`} target="_blank" rel="noopener noreferrer" className="text-[#3b82f6] hover:underline">{company.company_website}</a> : '—'}</td>
<td className="px-4 py-3 text-sm font-body text-white font-bold">{company.mention_count}</td>
<td className="px-4 py-3 text-xs text-[#888] font-body capitalize">{company.site_scope === 'both' ? 'Both' : company.site_scope === 'broadcastbeat' ? 'BB' : 'AV'}</td>
<td className="px-4 py-3 text-xs text-[#888] font-body capitalize">{company.site_scope === 'both' ? 'Both' : company.site_scope === 'avbeat' ? 'BB' : 'AV'}</td>
<td className="px-4 py-3">
<button onClick={() => handleToggleCompanyAutoStory(company.id, !company.auto_story_enabled)} disabled={actionLoading === company.id}
className={`relative w-9 h-5 rounded-full transition-colors ${company.auto_story_enabled ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}>

View File

@@ -47,7 +47,7 @@ interface LaunchChecklist {
// ─── Site labels ──────────────────────────────────────────────────────────────
const SITE_LABELS: Record<string, { label: string; color: string; target: string }> = {
'broadcast-beat': { label: 'Broadcast Beat', color: 'text-blue-400', target: '1525/day' },
'broadcast-beat': { label: 'AV Beat', color: 'text-blue-400', target: '1525/day' },
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '1015/day' },
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '1015/day' },
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '1015/day' },

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter, useSearchParams } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
function DnsRow({ label, valid, checkedAt }: { label: string; valid: boolean; checkedAt: string | null }) {
return (

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
function DnsCheck({ label, valid }: { label: string; valid: boolean | null }) {
return (

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
export default function SendLogPage() {
const { user, loading } = useAuth();

View File

@@ -4,13 +4,13 @@ import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
import { useRouter } from 'next/navigation';
const SITES = ['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
const SUPP_TYPES = ['unsubscribe','bounce_hard','bounce_soft','spam_complaint','hostile_reply','manual'];
export default function SuppressionsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [site, setSite] = useState('broadcastbeat');
const [site, setSite] = useState('avbeat');
const [suppressions, setSuppressions] = useState<any[]>([]);
const [keywords, setKeywords] = useState<any[]>([]);
const [stats, setStats] = useState<any>(null);

View File

@@ -158,8 +158,8 @@ export default function WPImportPage() {
<h1 className="text-2xl font-bold text-white font-heading">WordPress Post Importer</h1>
<p className="text-[#666] text-sm mt-1">
Import posts from{' '}
<a href="https://www.broadcastbeat.com" target="_blank" rel="noopener noreferrer" className="text-[#3b82f6] hover:underline">
broadcastbeat.com
<a href="https://www.avbeat.com" target="_blank" rel="noopener noreferrer" className="text-[#3b82f6] hover:underline">
avbeat.com
</a>{' '}
via WordPress REST API
</p>
@@ -228,7 +228,7 @@ export default function WPImportPage() {
<div className="w-5 h-5 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin flex-shrink-0" />
<div>
<p className="text-[#3b82f6] text-sm font-bold">Import in progress</p>
<p className="text-[#666] text-xs mt-0.5">Fetching posts from broadcastbeat.com and saving to database...</p>
<p className="text-[#666] text-xs mt-0.5">Fetching posts from avbeat.com and saving to database...</p>
</div>
</div>
<div className="mt-3 h-1.5 bg-[#1a2535] rounded-full overflow-hidden">
@@ -365,11 +365,11 @@ export default function WPImportPage() {
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<a
href={`https://www.broadcastbeat.com/${post.wp_slug}`}
href={`https://www.avbeat.com/${post.wp_slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-[#555] hover:text-[#3b82f6] transition-colors p-1"
title="View on broadcastbeat.com">
title="View on avbeat.com">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
</svg>

View File

@@ -632,7 +632,7 @@ export default function CampaignEditorPage() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-white">Broadcast Beat</span>
<span className="text-xs font-medium text-white">AV Beat</span>
<span className="text-[10px] text-[#444]">now</span>
</div>
<p className="text-xs text-[#888] truncate">{campaign.subject || 'Your subject line…'}</p>
@@ -645,7 +645,7 @@ export default function CampaignEditorPage() {
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
{/* Header */}
<div className="bg-[#0a0a0a] rounded-lg p-3 text-center border border-[#1a1a1a]">
<p className="text-xs font-bold text-white tracking-widest uppercase">Broadcast Beat</p>
<p className="text-xs font-bold text-white tracking-widest uppercase">AV Beat</p>
{campaign.previewText && (
<p className="text-[10px] text-[#555] mt-1">{campaign.previewText}</p>
)}
@@ -680,7 +680,7 @@ export default function CampaignEditorPage() {
{/* Footer */}
<div className="text-center pt-2 border-t border-[#1a1a1a]">
<p className="text-[9px] text-[#333]">Broadcast Beat · Unsubscribe · View in browser</p>
<p className="text-[9px] text-[#333]">AV Beat · Unsubscribe · View in browser</p>
</div>
</div>
</div>

View File

@@ -689,7 +689,7 @@ export default function AdminNewsletterPage() {
type="text"
value={digestSubject}
onChange={(e) => setDigestSubject(e.target.value)}
placeholder="e.g. Broadcast Beat Weekly — NAB Show Highlights"
placeholder="e.g. AV Beat Weekly — NAB Show Highlights"
className="search-input w-full py-2.5 px-3 text-sm"
/>
</div>

View File

@@ -435,7 +435,7 @@ export default function NewsletterTemplatesPage() {
type="text"
value={form.subject_prefix}
onChange={(e) => setForm((f) => ({ ...f, subject_prefix: e.target.value }))}
placeholder="e.g. Broadcast Beat Weekly —"
placeholder="e.g. AV Beat Weekly —"
className="search-input w-full py-2.5 px-3 text-sm"
/>
<p className="text-xs text-[#555] font-body mt-1">Prepended to the digest subject line when using this template</p>

View File

@@ -1,11 +1,11 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Advertise With Broadcast Beat — Reach Broadcast Engineering Professionals',
description: 'Advertise with Broadcast Beat to reach broadcast engineers, production professionals, and media technology decision-makers. Display ads, sponsored content, and newsletter placements.',
title: 'Advertise With AV Beat — Reach Broadcast Engineering Professionals',
description: 'Advertise with AV Beat to reach broadcast engineers, production professionals, and media technology decision-makers. Display ads, sponsored content, and newsletter placements.',
alternates: { canonical: '/advertise' },
openGraph: {
title: 'Advertise With Broadcast Beat',
title: 'Advertise With AV Beat',
description: 'Reach broadcast engineers, production professionals, and media technology decision-makers.',
url: '/advertise',
type: 'website',

View File

@@ -49,7 +49,7 @@ export default function AdvertisePage() {
<span className="section-label mb-3 inline-block">Advertise</span>
<h1 className="font-heading text-white text-4xl font-bold mb-4">Reach Broadcast Engineering Professionals</h1>
<p className="text-[#aaa] font-body text-lg max-w-2xl mx-auto mb-6">
Broadcast Beat is the leading digital platform for broadcast engineering professionals. Connect your brand with decision-makers, engineers, and executives across the broadcast industry.
AV Beat is the leading digital platform for broadcast engineering professionals. Connect your brand with decision-makers, engineers, and executives across the broadcast industry.
</p>
<a
href="#contact"
@@ -125,13 +125,13 @@ export default function AdvertisePage() {
<div>
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Advertising Sales</p>
<p className="font-body text-[#e0e0e0] font-medium">Jeff Victor</p>
<a href="mailto:jeff@broadcastbeat.com" className="font-body text-[#3b82f6] text-sm hover:underline">
jeff@broadcastbeat.com
<a href="mailto:jeff@avbeat.com" className="font-body text-[#3b82f6] text-sm hover:underline">
jeff@avbeat.com
</a>
</div>
<div className="border-t border-[#222] pt-4">
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Publication</p>
<p className="font-body text-[#777] text-sm">Broadcast Beat</p>
<p className="font-body text-[#777] text-sm">AV Beat</p>
<p className="font-body text-[#777] text-sm">Relevant Media Properties</p>
</div>
<div className="border-t border-[#222] pt-4">
@@ -161,7 +161,7 @@ export default function AdvertisePage() {
<h3 className="font-heading text-[#e0e0e0] text-xl font-bold mb-2">Message Sent!</h3>
<p className="font-body text-[#777] text-sm">
Thanks for reaching out. Jeff will be in touch within one business day at{" "}
<a href="mailto:jeff@broadcastbeat.com" className="text-[#3b82f6] hover:underline">jeff@broadcastbeat.com</a>.
<a href="mailto:jeff@avbeat.com" className="text-[#3b82f6] hover:underline">jeff@avbeat.com</a>.
</p>
</div>
) : (
@@ -245,7 +245,7 @@ export default function AdvertisePage() {
{loading ? "Sending..." : "Send Inquiry"}
</button>
<p className="text-[#555] text-xs font-body">
Or email directly: <a href="mailto:jeff@broadcastbeat.com" className="text-[#3b82f6] hover:underline">jeff@broadcastbeat.com</a>
Or email directly: <a href="mailto:jeff@avbeat.com" className="text-[#3b82f6] hover:underline">jeff@avbeat.com</a>
</p>
</form>
)}

View File

@@ -56,7 +56,7 @@ export async function GET(request: NextRequest) {
.select('site, amount_cents, status, paid_date')
.gte('created_at', yearStart);
const sites = ['broadcastbeat', 'avbeat', 'backlotbeat', 'broadcastengineering'];
const sites = ['avbeat', 'avbeat', 'backlotbeat', 'broadcastengineering'];
const siteRevenue = sites.map(site => {
const siteData = (siteInvoices ?? []).filter((i: any) => i.site === site);
const revenue_ytd = siteData.filter((i: any) => i.status === 'paid').reduce((s: number, r: any) => s + (r.amount_cents ?? 0), 0);

View File

@@ -81,7 +81,7 @@ export async function POST(req: NextRequest) {
// Trigger article publish notification if published immediately
if (status === 'published' && data) {
try {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
fetch(`${siteUrl}/api/newsletter/notify-article`, {
method: 'POST',
headers: {
@@ -152,7 +152,7 @@ export async function PATCH(req: NextRequest) {
.single();
if (articleData) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
// Fire-and-forget: call notify-article route internally
fetch(`${siteUrl}/api/newsletter/notify-article`, {
method: 'POST',

View File

@@ -13,9 +13,9 @@ export async function POST(request: NextRequest) {
});
await transport.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'Broadcast Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: 'editor@broadcastbeat.com',
subject: `[Broadcast Beat] Blocked post attempt — banned term triggered`,
from: `"${process.env.SMTP_FROM_NAME || 'AV Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: 'editor@avbeat.com',
subject: `[AV Beat] Blocked post attempt — banned term triggered`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
<h2 style="color:#ef4444;margin:0 0 16px;">Blocked Post Attempt</h2>

View File

@@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
const titleLower = (post.title || '').toLowerCase();
for (const bt of bannedTerms) {
const termLower = bt.term.toLowerCase();
const siteMatch = bt.site_scope === 'both' || (bt.site_scope === 'broadcastbeat' && post.site_id === 1) || (bt.site_scope === 'avbeat' && post.site_id === 2);
const siteMatch = bt.site_scope === 'both' || (bt.site_scope === 'avbeat' && post.site_id === 1) || (bt.site_scope === 'avbeat' && post.site_id === 2);
if (siteMatch && titleLower.includes(termLower)) {
matches.push({ title: post.title, author: post.author_name, published_at: post.published_at, site_id: post.site_id, matched_term: bt.term, ban_level: bt.ban_level });
break; // one match per post

View File

@@ -13,14 +13,14 @@ export async function POST(request: NextRequest) {
const companyList = (companies || []).slice(0, 10).map((c: string) => `<li>${c}</li>`).join('');
const moreText = companies?.length > 10 ? `<p style="color:#888;font-size:13px;">...and ${companies.length - 10} more</p>` : '';
await transport.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'Broadcast Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
from: `"${process.env.SMTP_FROM_NAME || 'AV Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: email || 'ryan.salazar@relevantmediaproperties.com',
subject: `[Relevant Media Properties] ${count} new company story suggestion${count !== 1 ? 's' : ''} queued`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
<div style="margin-bottom:24px;">
<h1 style="color:#3b82f6;font-size:20px;margin:0 0 4px;">Relevant Media Properties</h1>
<p style="color:#555;font-size:12px;margin:0;">Broadcast Beat · AV Beat — Company Story Engine</p>
<p style="color:#555;font-size:12px;margin:0;">AV Beat · AV Beat — Company Story Engine</p>
</div>
<h2 style="font-size:18px;color:#fff;margin:0 0 12px;">New Company Stories Queued for Review</h2>
<p style="color:#aaa;font-size:14px;line-height:1.6;margin:0 0 20px;">
@@ -31,12 +31,12 @@ export async function POST(request: NextRequest) {
<ul style="margin:0;padding-left:20px;color:#e5e5e5;font-size:14px;line-height:1.8;">${companyList}</ul>
${moreText}
</div>
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com'}/admin/company-tracker"
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com'}/admin/company-tracker"
style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:bold;">
Review Story Queue →
</a>
<p style="color:#444;font-size:12px;margin-top:24px;">
Manage settings at <a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com'}/admin/company-tracker" style="color:#3b82f6;">Company Tracker</a>
Manage settings at <a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com'}/admin/company-tracker" style="color:#3b82f6;">Company Tracker</a>
</p>
</div>
`,

View File

@@ -23,7 +23,7 @@ function pickPenName(siteId: number, recentNames: string[]): string {
const available = pool.filter(n => !recentNames.slice(-3).includes(n));
return available[Math.floor(Math.random() * available.length)] || pool[0];
}
// Broadcast Beat: fixed byline (Ryan) unless overridden to "rotate".
// AV Beat: fixed byline (Ryan) unless overridden to "rotate".
if (BB_AUTOSTORY_BYLINE.toLowerCase() === 'rotate') {
const pool = BB_PEN_NAMES_LEGACY;
const available = pool.filter(n => !recentNames.slice(-3).includes(n));
@@ -224,7 +224,7 @@ export async function POST(request: NextRequest) {
if ((count || 0) >= 3) continue; // max 3 per week
const siteId = article.site_id || 1;
const siteName = siteId === 2 ? 'AV Beat' : 'Broadcast Beat';
const siteName = siteId === 2 ? 'AV Beat' : 'AV Beat';
const penName = pickPenName(siteId, recentPenNames);
recentPenNames.push(penName);

View File

@@ -432,7 +432,7 @@ export async function POST(request: NextRequest) {
const penName = pickPenNameForShow(site_id, story_type, recentNames, preferred_pen_name);
const penNameData = (site_id === 2 ? AV_PEN_NAMES : BB_PEN_NAMES).find(p => p.name === penName);
const phase = getShowPhase(event);
const siteName = site_id === 2 ? 'AV Beat' : 'Broadcast Beat';
const siteName = site_id === 2 ? 'AV Beat' : 'AV Beat';
const story = await generateShowStory({
penName,

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import nodemailer from 'nodemailer';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
async function sendSuspensionEmail(
email: string,
@@ -35,8 +35,8 @@ async function sendSuspensionEmail(
: '';
const subject = isBanned
? `Your Broadcast Beat account has been banned`
: `Your Broadcast Beat account has been suspended`;
? `Your AV Beat account has been banned`
: `Your AV Beat account has been suspended`;
const html = `
<!DOCTYPE html>
@@ -45,7 +45,7 @@ async function sendSuspensionEmail(
<body style="background:#0d1117;color:#e5e7eb;font-family:system-ui,sans-serif;margin:0;padding:0;">
<div style="max-width:560px;margin:40px auto;padding:32px;background:#111827;border:1px solid #1f2937;border-radius:12px;">
<div style="margin-bottom:24px;">
<span style="font-size:24px;font-weight:800;color:#fff;">Broadcast Beat</span>
<span style="font-size:24px;font-weight:800;color:#fff;">AV Beat</span>
</div>
<div style="background:${isBanned ? '#7f1d1d' : '#78350f'};border:1px solid ${isBanned ? '#991b1b' : '#92400e'};border-radius:8px;padding:16px;margin-bottom:24px;">
<p style="margin:0;font-size:14px;font-weight:700;color:${isBanned ? '#fca5a5' : '#fcd34d'};">
@@ -54,7 +54,7 @@ async function sendSuspensionEmail(
</div>
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">Hi ${fullName || 'there'},</p>
<p style="color:#d1d5db;font-size:15px;line-height:1.6;">
Your Broadcast Beat account has been <strong style="color:#fff;">${isBanned ? 'permanently banned' : `suspended ${durationText}`}</strong>.
Your AV Beat account has been <strong style="color:#fff;">${isBanned ? 'permanently banned' : `suspended ${durationText}`}</strong>.
</p>
<div style="background:#0d1117;border:1px solid #1f2937;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px;color:#888;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Reason</p>
@@ -65,7 +65,7 @@ async function sendSuspensionEmail(
${isBanned ? 'This decision is final. If you believe this is an error, please contact our support team.' : 'During this period, you will not be able to post threads or replies. If you believe this is an error, please contact our support team.'}
</p>
<div style="margin-top:24px;padding-top:24px;border-top:1px solid #1f2937;">
<p style="margin:0;color:#555;font-size:12px;">Broadcast Beat &mdash; ${siteUrl}</p>
<p style="margin:0;color:#555;font-size:12px;">AV Beat &mdash; ${siteUrl}</p>
</div>
</div>
</body>
@@ -73,7 +73,7 @@ async function sendSuspensionEmail(
`;
await transporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'Broadcast Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
from: `"${process.env.SMTP_FROM_NAME || 'AV Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to: email,
subject,
html,

View File

@@ -107,7 +107,7 @@ export async function POST(req: NextRequest) {
// Send email via Supabase Edge Function
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
try {
const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-contributor-invite`, {
@@ -118,7 +118,7 @@ export async function POST(req: NextRequest) {
},
body: JSON.stringify({
email,
inviterName: inviterProfile?.full_name || inviterProfile?.email || 'Broadcast Beat Admin',
inviterName: inviterProfile?.full_name || inviterProfile?.email || 'AV Beat Admin',
role: role || 'contributor',
token,
message,

View File

@@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
</head>
<body>
<div class="site-header">
<div class="site-logo">Broadcast Beat</div>
<div class="site-logo">AV Beat</div>
<nav class="site-nav">
<a href="#">News</a>
<a href="#">Reviews</a>

View File

@@ -22,7 +22,7 @@ export async function POST(request: NextRequest) {
date: a.date,
}));
const systemPrompt = `You are a personalized content recommendation engine for Broadcast Beat, a broadcast engineering news platform.
const systemPrompt = `You are a personalized content recommendation engine for AV Beat, a broadcast engineering news platform.
Given a reader's topic interests and reading history, select the 4 most relevant articles from the provided article list.
Return ONLY a valid JSON array of slugs in order of relevance. Example: ["slug-1","slug-2","slug-3","slug-4"]
Do not include any explanation or markdown — only the raw JSON array.`;

View File

@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
try {
response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Broadcast Beat/1.0; +https://broadcastbeat.com)',
'User-Agent': 'Mozilla/5.0 (compatible; AV Beat/1.0; +https://avbeat.com)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
signal: controller.signal,

View File

@@ -24,11 +24,11 @@ function rateLimit(key: string): { ok: boolean; remaining: number } {
return { ok: true, remaining: RATE_LIMIT_MAX - b.count };
}
const SYSTEM_PROMPT = `You are BB AI, Broadcast Beat's intelligent research assistant.
const SYSTEM_PROMPT = `You are BB AI, AV Beat's intelligent research assistant.
You help readers — broadcast engineers, post-production supervisors,
streaming architects, and broadcast executives — find information from the
Broadcast Beat archive.
AV Beat archive.
Tone: trade-press, factual, lightly conversational. Plain English.

View File

@@ -0,0 +1,51 @@
/**
* GET /api/auth/lookup-email?email=foo@example.com
*
* Returns { known: true|false }. "Known" means the email is already on our
* subscriber list (bb.email_subscribers) OR has an auth.users entry. Used
* by the register page so existing newsletter readers get a magic-link
* login flow instead of being asked to create a new account.
*
* Privacy: returns only known/not-known, never any details — does not
* leak account existence beyond a binary signal (necessary for the UX).
* Rate-limited at the proxy layer.
*/
import { NextRequest, NextResponse } from "next/server";
import { createClient as createService } from "@supabase/supabase-js";
export async function GET(req: NextRequest) {
const email = (new URL(req.url).searchParams.get("email") || "").trim().toLowerCase();
if (!email || !email.includes("@")) {
return NextResponse.json({ known: false }, { status: 400 });
}
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
if (!url || !key) {
return NextResponse.json({ known: false });
}
const svc = createService(url, key, { auth: { persistSession: false } });
// Layer 1 — the rich subscriber registry (95k+ rows)
const { data: sub } = await svc
.schema("bb")
.from("email_subscribers")
.select("id, status")
.eq("email_domain", email.split("@")[1])
.ilike("email", email)
.limit(1)
.maybeSingle();
if (sub) return NextResponse.json({ known: true, source: "subscriber" });
// Layer 2 — the lean property-specific list
const { data: nl } = await svc
.schema("bb")
.from("newsletter_subscribers")
.select("id")
.ilike("email", email)
.limit(1)
.maybeSingle();
if (nl) return NextResponse.json({ known: true, source: "newsletter" });
return NextResponse.json({ known: false });
}

View File

@@ -0,0 +1,72 @@
/**
* POST /api/comments/[id]/vote { vote: 1 | -1 | 0 }
*
* Up/down/clear vote on an article comment. Uses the existing
* polymorphic forum_votes table (target_type='comment').
*
* 0 → remove your existing vote (toggle).
*/
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) return NextResponse.json({ error: "Sign in to vote" }, { status: 401 });
let body: { vote?: number };
try { body = await req.json(); }
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
const v = body.vote === 1 ? 1 : body.vote === -1 ? -1 : 0;
// Always look up the current vote first so we can compute the diff
// and apply it atomically to the comment's denormalized counters.
const { data: existing } = await sb
.from("forum_votes")
.select("id, vote_value")
.eq("user_id", user.id)
.eq("target_type", "comment")
.eq("target_id", id)
.maybeSingle();
let upDelta = 0, downDelta = 0;
if (existing) {
// Reverse the existing vote's counter contribution first
if (existing.vote_value === 1) upDelta -= 1;
else if (existing.vote_value === -1) downDelta -= 1;
if (v === 0) {
await sb.from("forum_votes").delete().eq("id", existing.id);
} else {
await sb.from("forum_votes")
.update({ vote_value: v, updated_at: new Date().toISOString() })
.eq("id", existing.id);
if (v === 1) upDelta += 1; else downDelta += 1;
}
} else if (v !== 0) {
await sb.from("forum_votes").insert({
user_id: user.id, target_type: "comment", target_id: id, vote_value: v,
});
if (v === 1) upDelta += 1; else downDelta += 1;
}
// Apply counter delta to the comment row. Use rpc/raw fragment to add
// integers atomically (supabase-js doesn't expose increment; we read &
// write — racy under heavy concurrency but acceptable for forum scale).
const { data: cur } = await sb
.from("article_comments")
.select("upvotes, downvotes")
.eq("id", id)
.maybeSingle();
if (cur) {
const upvotes = Math.max(0, (cur.upvotes || 0) + upDelta);
const downvotes = Math.max(0, (cur.downvotes || 0) + downDelta);
await sb.from("article_comments").update({
upvotes, downvotes, vote_score: upvotes - downvotes,
}).eq("id", id);
}
return NextResponse.json({ ok: true, vote: v });
}

View File

@@ -0,0 +1,100 @@
/**
* Article comments API.
*
* GET /api/comments?article_slug=<slug> → list, threaded by parent
* POST /api/comments → create (auth required)
*
* Reads: public — anyone can fetch.
* Writes: signed-in users only. We assert via supabase auth and use the
* user's forum_user_profile for the denormalized display fields so the
* client can render avatar/name without an extra join per comment.
*/
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
interface Row {
id: string;
article_slug: string;
parent_comment_id: string | null;
user_id: string | null;
content: string;
author_username: string | null;
author_display_name: string | null;
author_avatar_url: string | null;
is_ai_seeded: boolean;
upvotes: number;
downvotes: number;
vote_score: number;
status: string;
created_at: string;
updated_at: string;
}
export async function GET(req: NextRequest) {
const slug = new URL(req.url).searchParams.get("article_slug");
if (!slug) return NextResponse.json({ error: "article_slug required" }, { status: 400 });
const sb = await createClient();
const { data, error } = await sb
.from("article_comments")
.select("*")
.eq("article_slug", slug)
.eq("status", "published")
.order("vote_score", { ascending: false })
.order("created_at", { ascending: true })
.limit(500);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ comments: data || [] });
}
export async function POST(req: NextRequest) {
const sb = await createClient();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json(
{ error: "Sign in to comment", redirectTo: "/forum/login" },
{ status: 401 },
);
}
let body: { article_slug?: string; content?: string; parent_comment_id?: string | null };
try { body = await req.json(); }
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
const slug = (body.article_slug || "").trim();
const content = (body.content || "").trim();
if (!slug || !content) {
return NextResponse.json({ error: "article_slug and content required" }, { status: 400 });
}
if (content.length > 5000) {
return NextResponse.json({ error: "Comment too long (5000 char max)" }, { status: 400 });
}
// Pull the user's forum profile so we have display name + avatar for
// denormalized rendering (saves a per-comment join later).
const { data: prof } = await sb
.from("forum_user_profiles")
.select("username, display_name, avatar_url")
.eq("user_id", user.id)
.maybeSingle();
const ins = {
article_slug: slug,
user_id: user.id,
content,
parent_comment_id: body.parent_comment_id || null,
is_ai_seeded: false,
author_username: prof?.username || null,
author_display_name: prof?.display_name || user.email?.split("@")[0] || "Member",
author_avatar_url: prof?.avatar_url || null,
};
const { data, error } = await sb
.from("article_comments")
.insert(ins)
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ comment: data });
}

View File

@@ -85,7 +85,7 @@ export async function POST(request: NextRequest) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: profile.full_name || user.email, term: bannedMatch.term }),
}).catch(() => {});
return NextResponse.json({ blocked: true, error: 'This post could not be submitted. Please contact the editorial team at editor@broadcastbeat.com for assistance.' }, { status: 422 });
return NextResponse.json({ blocked: true, error: 'This post could not be submitted. Please contact the editorial team at editor@avbeat.com for assistance.' }, { status: 422 });
}
// Soft ban: flag but allow through
// Will be tagged below

View File

@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
.eq('domain', sendingDomain)
.single();
const site = domainRecord?.site ?? 'broadcastbeat';
const site = domainRecord?.site ?? 'avbeat';
// Load active keywords
const { data: keywords } = await supabase

View File

@@ -22,7 +22,7 @@ interface Row {
export async function GET() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
const schema = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
const schema = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av";
if (!url || !key) return NextResponse.json({ events: [] }, { status: 200 });
const sb = createClient(url, key, {

View File

@@ -19,7 +19,7 @@ export async function POST(req: NextRequest) {
[
{
role: 'system',
content: `You are the Broadcast Beat AI Assistant — a knowledgeable, helpful assistant for broadcast engineering professionals. You have deep expertise in broadcast technology including live production, IP workflows, SMPTE ST 2110, audio engineering, cameras, streaming, post-production, MAM systems, and broadcast industry trends.
content: `You are the AV Beat AI Assistant — a knowledgeable, helpful assistant for broadcast engineering professionals. You have deep expertise in broadcast technology including live production, IP workflows, SMPTE ST 2110, audio engineering, cameras, streaming, post-production, MAM systems, and broadcast industry trends.
You are responding in a community forum. Be helpful, technically accurate, and conversational. Acknowledge when a question is complex or when professional consultation is advisable. Keep responses focused and practical — broadcast engineers value concise, actionable information.
@@ -51,7 +51,7 @@ Please provide a helpful, technically accurate response for this broadcast engin
.insert({
thread_id,
body: aiResponseText,
author_name: 'Broadcast Beat AI',
author_name: 'AV Beat AI',
is_ai_response: true,
})
.select()

View File

@@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
});
await transporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME || 'Broadcast Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
from: `"${process.env.SMTP_FROM_NAME || 'AV Beat'}" <${process.env.SMTP_FROM_EMAIL}>`,
to,
subject,
html,

View File

@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Article shared with you — Broadcast Beat</title>
<title>Article shared with you — AV Beat</title>
</head>
<body style="margin:0;padding:0;background:#0d0d0d;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0d0d0d;padding:32px 16px;">
@@ -45,7 +45,7 @@ export async function POST(req: NextRequest) {
<!-- Header -->
<tr>
<td style="background:#0a0a0a;border-bottom:1px solid #1a1a1a;padding:20px 28px;">
<p style="margin:0;color:#3b82f6;font-size:11px;font-weight:700;letter-spacing:3px;text-transform:uppercase;">Broadcast Beat</p>
<p style="margin:0;color:#3b82f6;font-size:11px;font-weight:700;letter-spacing:3px;text-transform:uppercase;">AV Beat</p>
</td>
</tr>
<!-- Body -->
@@ -62,8 +62,8 @@ export async function POST(req: NextRequest) {
<!-- Footer -->
<tr>
<td style="background:#0a0a0a;border-top:1px solid #1a1a1a;padding:16px 28px;">
<p style="margin:0;color:#444;font-size:11px;">You received this because someone shared a Broadcast Beat article with you.</p>
<p style="margin:6px 0 0;color:#333;font-size:11px;">© ${new Date().getFullYear()} Broadcast Beat. All rights reserved.</p>
<p style="margin:0;color:#444;font-size:11px;">You received this because someone shared a AV Beat article with you.</p>
<p style="margin:6px 0 0;color:#333;font-size:11px;">© ${new Date().getFullYear()} AV Beat. All rights reserved.</p>
</td>
</tr>
</table>
@@ -74,7 +74,7 @@ export async function POST(req: NextRequest) {
</html>`;
await transporter.sendMail({
from: `"${process.env.SMTP_FROM_NAME || "Broadcast Beat"}" <${process.env.SMTP_FROM_EMAIL}>`,
from: `"${process.env.SMTP_FROM_NAME || "AV Beat"}" <${process.env.SMTP_FROM_EMAIL}>`,
to,
subject: `Shared article: ${articleTitle}`,
html,

View File

@@ -68,8 +68,8 @@ async function sendWelcome(email: string, topics?: string[]) {
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
const fromName = process.env.SMTP_FROM_NAME || 'Broadcast Beat';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
const fromName = process.env.SMTP_FROM_NAME || 'AV Beat';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
if (!smtpHost || !smtpUser || !smtpPass || !fromEmail) {
// SMTP not configured — signal that to logs but don't blow up.
@@ -92,18 +92,18 @@ async function sendWelcome(email: string, topics?: string[]) {
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: email,
subject: 'Welcome to Broadcast Beat',
subject: 'Welcome to AV Beat',
html: `
<!DOCTYPE html>
<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>Welcome to Broadcast Beat</title></head>
<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>Welcome to AV Beat</title></head>
<body style="margin:0;padding:0;background:#0a0e15;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#0a0e15;padding:40px 16px;">
<tr><td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;background:#11192a;border:1px solid #1e3a5f;border-radius:10px;overflow:hidden;">
<tr><td style="padding:32px 36px 8px;">
<div style="font-family:'Courier New',monospace;font-size:10px;font-weight:700;letter-spacing:0.22em;text-transform:uppercase;color:#3b82f6;">Broadcast Beat Newsletter</div>
<div style="font-family:'Courier New',monospace;font-size:10px;font-weight:700;letter-spacing:0.22em;text-transform:uppercase;color:#3b82f6;">AV Beat Newsletter</div>
<h1 style="margin:14px 0 8px;font-size:24px;line-height:1.2;color:#ffffff;font-weight:800;">You're in</h1>
<p style="margin:0;color:#cfd6e1;font-size:15px;line-height:1.55;">Thanks for subscribing to <strong style="color:#ffffff;">Broadcast Beat</strong> — the digital platform for broadcast engineering professionals.</p>
<p style="margin:0;color:#cfd6e1;font-size:15px;line-height:1.55;">Thanks for subscribing to <strong style="color:#ffffff;">AV Beat</strong> — the digital platform for broadcast engineering professionals.</p>
${topicsBlock}
</td></tr>
<tr><td style="padding:18px 36px 8px;">
@@ -113,7 +113,7 @@ async function sendWelcome(email: string, topics?: string[]) {
<a href="${siteUrl}/news" style="display:inline-block;background:#3b82f6;color:#ffffff;text-decoration:none;padding:11px 22px;border-radius:6px;font-weight:700;font-size:13px;letter-spacing:0.05em;text-transform:uppercase;">Browse Industry News</a>
</td></tr>
<tr><td style="padding:18px 36px 28px;border-top:1px solid #1e3a5f;">
<p style="margin:0;color:#5a6470;font-size:11px;line-height:1.5;">You're receiving this because you signed up at <a href="${siteUrl}" style="color:#3b82f6;text-decoration:none;">broadcastbeat.com</a>. <a href="${siteUrl}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#98a3b3;">Unsubscribe anytime</a>.</p>
<p style="margin:0;color:#5a6470;font-size:11px;line-height:1.5;">You're receiving this because you signed up at <a href="${siteUrl}" style="color:#3b82f6;text-decoration:none;">avbeat.com</a>. <a href="${siteUrl}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#98a3b3;">Unsubscribe anytime</a>.</p>
</td></tr>
</table>
</td></tr>

View File

@@ -95,8 +95,8 @@ export async function POST(req: NextRequest) {
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
const fromName = process.env.SMTP_FROM_NAME || 'Broadcast Beat';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
const fromName = process.env.SMTP_FROM_NAME || 'AV Beat';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
if (!smtpHost || !smtpUser || !smtpPass) {
return NextResponse.json({ error: 'SMTP credentials not configured' }, { status: 500 });
@@ -140,7 +140,7 @@ export async function POST(req: NextRequest) {
<div style="background:#0d1520;padding:0;font-family:sans-serif;max-width:600px;margin:0 auto;">
<!-- Header -->
<div style="background:#111;border-bottom:2px solid #3b82f6;padding:24px 32px;">
<h1 style="color:#3b82f6;font-size:22px;margin:0;font-weight:bold;">Broadcast Beat</h1>
<h1 style="color:#3b82f6;font-size:22px;margin:0;font-weight:bold;">AV Beat</h1>
<p style="color:#555;font-size:12px;margin:4px 0 0;">News & Intelligence for Broadcast, Post-Production & Streaming Technology</p>
</div>
<!-- Body -->
@@ -151,9 +151,9 @@ export async function POST(req: NextRequest) {
</div>
<!-- Footer -->
<div style="background:#0a0f1a;padding:24px 32px;border-top:1px solid #1e3a5f;">
<p style="color:#555;font-size:12px;margin:0;">You're receiving this because you subscribed at broadcastbeat.com.</p>
<p style="color:#555;font-size:12px;margin:0;">You're receiving this because you subscribed at avbeat.com.</p>
<p style="color:#555;font-size:12px;margin:8px 0 0;">
<a href="${siteUrl}" style="color:#3b82f6;">Visit Broadcast Beat</a> ·
<a href="${siteUrl}" style="color:#3b82f6;">Visit AV Beat</a> ·
<a href="${siteUrl}/unsubscribe?email={{email}}" style="color:#3b82f6;">Unsubscribe</a>
</p>
</div>

View File

@@ -42,7 +42,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ slug: s
const { count } = await advSvc
.from("active_advertisers_by_property")
.select("client_id", { count: "exact", head: true })
.eq("property", "broadcastbeat")
.eq("property", "avbeat")
.eq("company_name_lower", needle.toLowerCase());
isActiveAdvertiser = (count || 0) > 0;
} catch { /* tolerate adv lookup failures */ }

View File

@@ -35,7 +35,7 @@ export async function GET(request: Request) {
// Decorate + reorder so premium and current-advertiser stories surface
// at the top of the feed.
const decorated = await decorateHomepageFeed("broadcastbeat", base);
const decorated = await decorateHomepageFeed("avbeat", base);
const payload = decorated.map((d) => ({
...d.article,
promoted_kind: d.promoted_kind,

View File

@@ -6,7 +6,7 @@ export const dynamic = "force-dynamic";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av";
function client(schema = SCHEMA) {
return createClient(SUPABASE_URL, SUPABASE_ANON, {
@@ -75,7 +75,7 @@ export async function GET(req: Request) {
const { data: ads } = await adv
.from("active_advertisers_by_property")
.select("company_name_lower")
.eq("property", "broadcastbeat");
.eq("property", "avbeat");
sponsors = new Set((ads || []).map((r: any) => String(r.company_name_lower)));
} catch { /* tolerate */ }

View File

@@ -58,7 +58,7 @@ export async function POST(request: NextRequest) {
const featuredImage = body?.featured_image_url || null;
const attachments = Array.isArray(body?.attachments) ? body.attachments : [];
const submissionId = body?.submission_id || null;
const property = body?.property || 'broadcastbeat';
const property = body?.property || 'avbeat';
// Defense in depth: re-check the contributor's approval status against
// distribute.users. distribute-rmp's submit route already gates, but a

View File

@@ -0,0 +1,119 @@
/**
* POST /api/upload/image
*
* Universal image upload endpoint. Every surface (forum composer, PR
* submission, contributor article, featured-story admin, avatar update,
* banner upload, anything else) POSTs here.
*
* Auth: requires a logged-in Supabase user. Anonymous uploads are
* rejected to keep the bucket from being abused as a public file dump.
*
* Pipeline (see lib/images/optimize.ts):
* - sharp resize to ≤4096px longest side
* - AVIF + WebP + format-preserving fallback (JPEG/PNG) at q=82
* - 320px thumbnail
*
* Storage: writes to the `bb-media` Supabase bucket under
* /<user_id>/<yyyy>/<mm>/<random>.{webp,avif,jpg,png}
* and returns the public URLs + dimensions so the client can either
* embed an <img srcset> or paste a markdown ![alt](url) into their post.
*/
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { createClient as createServer } from "@/lib/supabase/server";
import { createClient as createService } from "@supabase/supabase-js";
import { optimizeImage } from "@/lib/images/optimize";
const BUCKET = "bb-media";
const MAX_INPUT_BYTES = 20 * 1024 * 1024; // 20 MB hard cap on input
export async function POST(req: NextRequest) {
// ── 1. Auth ──────────────────────────────────────────────────────────────
const sb = await createServer();
const { data: { user } } = await sb.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
}
// ── 2. Read upload ───────────────────────────────────────────────────────
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > MAX_INPUT_BYTES) {
return NextResponse.json(
{ error: `Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 20 MB.` },
{ status: 413 },
);
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Not an image" }, { status: 415 });
}
const input = Buffer.from(await file.arrayBuffer());
// ── 3. Optimize ──────────────────────────────────────────────────────────
let opt;
try {
opt = await optimizeImage(input);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "image decode failed";
return NextResponse.json({ error: `Could not process image: ${msg}` }, { status: 422 });
}
// ── 4. Upload derivatives ────────────────────────────────────────────────
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
if (!url || !key) {
return NextResponse.json({ error: "Storage not configured" }, { status: 500 });
}
const svc = createService(url, key, { auth: { persistSession: false } });
const now = new Date();
const dir = `${user.id}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
const stub = randomBytes(8).toString("hex");
async function upload(suffix: string, body: Buffer, contentType: string): Promise<string | null> {
const path = `${dir}/${stub}${suffix}`;
const { error } = await svc.storage.from(BUCKET).upload(path, body, {
contentType, upsert: false, cacheControl: "31536000, immutable",
});
if (error) {
console.error("storage upload failed", path, error);
return null;
}
return svc.storage.from(BUCKET).getPublicUrl(path).data.publicUrl;
}
const urls: Record<string, string | null> = {};
if (opt.avif) urls.avif = await upload(".avif", opt.avif, "image/avif");
/* webp is mandatory */ urls.webp = await upload(".webp", opt.webp, "image/webp");
if (opt.jpeg) urls.jpeg = await upload(".jpg", opt.jpeg, "image/jpeg");
if (opt.png) urls.png = await upload(".png", opt.png, "image/png");
if (opt.thumbnail) urls.thumbnail = await upload("_thumb.jpg", opt.thumbnail, "image/jpeg");
if (!urls.webp) {
return NextResponse.json({ error: "Storage upload failed" }, { status: 500 });
}
// ── 5. Return URLs + metadata ───────────────────────────────────────────
return NextResponse.json({
ok: true,
width: opt.width,
height: opt.height,
format: opt.format,
hasAlpha: opt.hasAlpha,
urls,
bytes: opt.bytes,
// Convenience: the most-compatible URL the caller should embed by default.
// Browsers that support AVIF will pick it via <picture>; this URL is the
// safe-default <img src=>.
primaryUrl: urls.webp,
});
}

View File

@@ -3,7 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '../../../lib/supabase/server';
const WP_BASE_URL = 'https://www.broadcastbeat.com/wp-json/wp/v2';
const WP_BASE_URL = 'https://www.avbeat.com/wp-json/wp/v2';
const PER_PAGE = 20;
const IMAGE_BUCKET = 'post-images';
@@ -29,7 +29,7 @@ function stripHtml(html: string): string {
async function fetchWPPosts(page: number): Promise<{ posts: WPPost[]; totalPages: number }> {
const url = `${WP_BASE_URL}/posts?page=${page}&per_page=${PER_PAGE}&_embed=1&status=publish`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Broadcast Beat-Importer/1.0' },
headers: { 'User-Agent': 'AV Beat-Importer/1.0' },
next: { revalidate: 0 },
});
@@ -54,7 +54,7 @@ async function downloadAndStoreImage(
try {
// Fetch the image
const response = await fetch(imageUrl, {
headers: { 'User-Agent': 'Broadcast Beat-Importer/1.0' },
headers: { 'User-Agent': 'AV Beat-Importer/1.0' },
signal: AbortSignal.timeout(15000),
});

View File

@@ -8,11 +8,14 @@ import SidebarAdStack from "@/components/SidebarAdStack";
import StarRating from "@/components/StarRating";
import { createClient } from "@/lib/supabase/client";
import type { Article } from "@/lib/articles/types";
import type { RelatedForumThread } from "@/lib/articles/legacy-source";
import ArticleComments from "@/components/ArticleComments";
// DO NOT OVERRIDE — ArticleDetailClient interface and session tracking
interface ArticleDetailClientProps {
article: Article;
relatedArticles: Article[];
relatedForumThreads?: RelatedForumThread[];
}
function getSessionId(): string {
@@ -25,7 +28,7 @@ function getSessionId(): string {
return sid;
}
export default function ArticleDetailClient({ article, relatedArticles }: ArticleDetailClientProps) {
export default function ArticleDetailClient({ article, relatedArticles, relatedForumThreads = [] }: ArticleDetailClientProps) {
const [isSaved, setIsSaved] = useState(false);
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
const [saveToast, setSaveToast] = useState<string | null>(null);
@@ -255,8 +258,6 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
<div className="mb-4">
<StarRating seed={article.slug} publishedAt={article.publishedAt || article.date} size="md" />
</div>
<p className="font-body text-lg text-[#999] mb-6 leading-relaxed">{article.excerpt}</p>
{/* Article Meta */}
<div className="flex items-center justify-between py-4 border-t border-b border-[#222]">
<div className="flex items-center gap-3">
@@ -280,7 +281,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
{(() => {
const articleUrl = typeof window !== "undefined"
? window.location.href
: `https://broadcastbeat.com/articles/${article.slug}`;
: `https://avbeat.com/articles/${article.slug}`;
return (
<>
<a href={`mailto:?subject=${encodeURIComponent(article.title)}&body=${encodeURIComponent(articleUrl)}`}
@@ -386,6 +387,10 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
Back to {sectionLabel}
</Link>
</div>
{/* Article comments — anonymous read, signed-in members can
post + vote + reply. AI personas participate too (tagged). */}
<ArticleComments articleSlug={article.slug} />
</div>
{/* Sidebar */}
@@ -424,6 +429,45 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
</div>
</div>
{/* Related Forum Posts — surfaces broadcast-pro discussion
threads relevant to the same topic/keyword as the article.
Title-keyword match, recency-ordered. Falls back to most-
recently-active threads so the box is never empty. */}
{relatedForumThreads.length > 0 && (
<div className="bg-[#111] border border-[#222] p-5">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
Related Forum Posts
</h3>
<ul className="space-y-3.5">
{relatedForumThreads.map((t) => (
<li key={t.id}>
<Link href={`/forum/thread/${t.id}`} className="group block">
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
{t.title}
</p>
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
{t.category_name && (
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
{t.category_name}
</span>
)}
<span>·</span>
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
{t.vote_score > 0 && <><span>·</span><span> {t.vote_score}</span></>}
</p>
</Link>
</li>
))}
</ul>
<Link
href="/forum"
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
>
Browse all forum threads
</Link>
</div>
)}
{/* Sidebar ad stack — 300x600 + 300x250 rotating */}
<SidebarAdStack />
</aside>

View File

@@ -6,6 +6,7 @@ import {
getLegacyArticleBySlug,
getLegacyRecentSlugs,
getLegacyRelatedArticles,
getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source";
export const revalidate = 3600;
@@ -16,7 +17,7 @@ interface PageProps {
}
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://broadcastbeat.com";
process.env.NEXT_PUBLIC_SITE_URL || "https://avbeat.com";
function toIso(d: string | null | undefined): string {
if (!d) return new Date().toISOString();
@@ -33,11 +34,15 @@ function absoluteImage(src: string | undefined | null): string {
async function loadArticle(slug: string): Promise<{
article: Article | null;
related: Article[];
relatedForumThreads: RelatedForumThread[];
}> {
const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [] };
const related = await getLegacyRelatedArticles(slug, imported.category, 3);
return { article: imported, related };
if (!imported) return { article: null, related: [], relatedForumThreads: [] };
const [related, relatedForumThreads] = await Promise.all([
getLegacyRelatedArticles(slug, imported.category, 3),
getRelatedForumThreads(imported.title, 6),
]);
return { article: imported, related, relatedForumThreads };
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -65,7 +70,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: article.title,
description: article.excerpt,
images: [article.image],
creator: "@Broadcast Beat",
creator: "@AV Beat",
},
};
}
@@ -77,7 +82,7 @@ export async function generateStaticParams() {
export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params;
const { article, related } = await loadArticle(slug);
const { article, related, relatedForumThreads } = await loadArticle(slug);
if (!article) {
// Slug isn't in any of our article tables — send the reader to the news
// index so a click from the homepage/ticker doesn't dead-end on 404.
@@ -93,7 +98,7 @@ export default async function ArticlePage({ params }: PageProps) {
author: { "@type": "Person", name: article.author },
publisher: {
"@type": "Organization",
name: "Broadcast Beat",
name: "AV Beat",
logo: { "@type": "ImageObject", url: `${SITE_URL}/assets/images/logo.png` },
},
datePublished: toIso(article.date),
@@ -107,7 +112,7 @@ export default async function ArticlePage({ params }: PageProps) {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<ArticleDetailPage article={article} relatedArticles={related} />
<ArticleDetailPage article={article} relatedArticles={related} relatedForumThreads={relatedForumThreads} />
</>
);
}

View File

@@ -62,7 +62,7 @@ export default function ClientLoginPage() {
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Broadcast Beat</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">AV Beat</h1>
<p className="text-gray-600">Contributor Portal</p>
</div>

View File

@@ -62,7 +62,7 @@ const AV_CATEGORIES = [
];
const SITE_CONFIG = {
1: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]', borderClass: 'border-[#0ea5e9]/30' },
1: { name: 'AV Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]', borderClass: 'border-[#0ea5e9]/30' },
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]', borderClass: 'border-[#f97316]/30' },
};
@@ -162,7 +162,7 @@ export default function ContributorDashboard() {
});
const data = await res.json();
if (data.scope === 'broadcast' && data.confidence > 0.75) {
setScopeWarning('This content may be better suited for Broadcast Beat. AV Beat covers professional AV integration. Your post has been flagged for editorial review.');
setScopeWarning('This content may be better suited for AV Beat. AV Beat covers professional AV integration. Your post has been flagged for editorial review.');
} else {
setScopeWarning(null);
}
@@ -194,7 +194,7 @@ export default function ContributorDashboard() {
const data = await res.json();
if (!res.ok) {
if (data.blocked) {
showNotification('error', 'This post could not be submitted. Please contact the editorial team at editor@broadcastbeat.com for assistance.');
showNotification('error', 'This post could not be submitted. Please contact the editorial team at editor@avbeat.com for assistance.');
} else {
showNotification('error', data.error || 'Failed to submit post.');
}
@@ -361,7 +361,7 @@ export default function ContributorDashboard() {
<div className="text-sm font-semibold text-white mb-3">Publish To</div>
<div className="flex gap-3">
{[
{ id: 1, name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9' },
{ id: 1, name: 'AV Beat', badge: 'BB', color: '#0ea5e9' },
{ id: 2, name: 'AV Beat', badge: 'AV', color: '#f97316' },
].map(site => (
<button

View File

@@ -47,7 +47,7 @@ interface Stats {
}
const PUBLICATIONS = [
{ id: 'broadcast-beat', name: 'Broadcast Beat', short: 'BB' },
{ id: 'broadcast-beat', name: 'AV Beat', short: 'BB' },
{ id: 'av-beat', name: 'AV Beat', short: 'AV' },
];
@@ -218,7 +218,7 @@ export default function AdOpsDashboard() {
</button>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-orange-900/50 text-orange-300 border border-orange-700">
<span className="w-1.5 h-1.5 rounded-full bg-orange-400" />
adops@broadcastbeat.com
adops@avbeat.com
</span>
</div>
</div>

View File

@@ -191,9 +191,9 @@ const QUICK_ACTIONS = [
];
const QUICK_ACTION_TEMPLATES: Record<string, string> = {
url_story: `Write a news story for [Broadcast Beat / AV Beat] based on this URL: [URL]. Use the byline [pen name]. Target length: [400-600] words. Focus angle: [product launch / company news / industry trend].`,
url_story: `Write a news story for [AV Beat / AV Beat] based on this URL: [URL]. Use the byline [pen name]. Target length: [400-600] words. Focus angle: [product launch / company news / industry trend].`,
exhibitor_preview: `Write a pre-show exhibitor preview for [company name] at [NAB Show / InfoComm] [year]. They will be showing [product/technology]. Booth: [number if known]. Use [pen name] byline. 400-500 words.`,
press_release: `Rewrite the following press release as a real news story for [Broadcast Beat / AV Beat]. Remove marketing language, lead with the news hook, add industry context. Byline: [pen name].
press_release: `Rewrite the following press release as a real news story for [AV Beat / AV Beat]. Remove marketing language, lead with the news hook, add industry context. Byline: [pen name].
[PASTE PRESS RELEASE HERE]`,
nab_story: `Write a [story type: preview / announcement / recap] story about [topic/company] for NAB Show [year]. Include relevant SEO keywords. Byline: James Mercer. [400-800] words.`,
@@ -203,7 +203,7 @@ Story headline: [HEADLINE]
Story summary: [SUMMARY]`,
headlines: `Generate 8 headline options for this story. Include 2 SEO-optimized versions targeting [keyword], 2 social media-friendly versions, 2 trade publication style versions, and 2 alternatives. Story summary: [SUMMARY]`,
translate: `Translate the following story into [language]. Maintain the professional trade publication tone. Keep technical terms in their internationally recognized form. [PASTE STORY HERE]`,
company_bio: `Write a 150-word company bio for [company name] suitable for use as a boilerplate paragraph at the end of news stories on [Broadcast Beat / AV Beat]. The company makes [products/services]. Website: [URL].`,
company_bio: `Write a 150-word company bio for [company name] suitable for use as a boilerplate paragraph at the end of news stories on [AV Beat / AV Beat]. The company makes [products/services]. Website: [URL].`,
product_desc: `Write a 200-word product description for [product name] by [company]. Key features: [features]. Target audience: [integrators / broadcast engineers / etc]. Suitable for use in a product announcement story.`,
};
@@ -243,7 +243,7 @@ function DraftPreviewModal({ content, title, penName, siteId, onClose, onSave }:
<div className="flex items-center justify-between px-5 py-4 border-b border-[#1e2a3a]">
<div>
<h2 className="text-white font-semibold text-sm">Draft Preview</h2>
<p className="text-[#555] text-xs mt-0.5">Review before saving to {siteId === 1 ? 'Broadcast Beat' : 'AV Beat'}</p>
<p className="text-[#555] text-xs mt-0.5">Review before saving to {siteId === 1 ? 'AV Beat' : 'AV Beat'}</p>
</div>
<button onClick={onClose} className="text-[#555] hover:text-white transition-colors text-lg leading-none"></button>
</div>
@@ -256,7 +256,7 @@ function DraftPreviewModal({ content, title, penName, siteId, onClose, onSave }:
<div className="flex items-center gap-2">
<span className="text-[#555] text-xs">Site:</span>
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${siteId === 2 ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>
{siteId === 2 ? 'AV Beat' : 'Broadcast Beat'}
{siteId === 2 ? 'AV Beat' : 'AV Beat'}
</span>
</div>
<div className="flex items-center gap-2">
@@ -487,7 +487,7 @@ function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
onClick={() => setFilterSite(f)}
className={`text-xs px-2.5 py-1 rounded transition-colors ${filterSite === f ? 'bg-[#3b82f6] text-white' : 'bg-[#1e2a3a] text-[#aaa] hover:text-white'}`}
>
{f === 'ALL' ? 'All Writers' : f === 'BB' ? 'Broadcast Beat' : 'AV Beat'}
{f === 'ALL' ? 'All Writers' : f === 'BB' ? 'AV Beat' : 'AV Beat'}
</button>
))}
</div>
@@ -670,7 +670,7 @@ export default function AIConsolePage() {
const penNames = selectedSite === 1 ? BB_PEN_NAMES : AV_PEN_NAMES;
const buildSystemPrompt = () => {
const siteName = selectedSite === 1 ? 'Broadcast Beat' : 'AV Beat';
const siteName = selectedSite === 1 ? 'AV Beat' : 'AV Beat';
const siteDesc = selectedSite === 1
? 'a professional B2B trade publication for broadcast engineers, production professionals, and industry executives'
: 'a professional B2B trade publication for AV integrators, technology managers, and AV industry professionals';
@@ -1068,7 +1068,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
onChange={e => setSelectedSite(Number(e.target.value) as 1 | 2)}
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#3b82f6]"
>
<option value={1}>Broadcast Beat</option>
<option value={1}>AV Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>

View File

@@ -303,12 +303,12 @@ export default function AIConsoleSettingsPage() {
onChange={e => update('default_site_id', Number(e.target.value))}
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
>
<option value={1}>Broadcast Beat</option>
<option value={1}>AV Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>
<div>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Pen Name (Broadcast Beat)</label>
<label className="block text-[#aaa] text-xs font-medium mb-1.5">Default Pen Name (AV Beat)</label>
<select
value={settings.default_pen_name_bb}
onChange={e => update('default_pen_name_bb', e.target.value)}

View File

@@ -67,7 +67,7 @@ type TabType = 'calendar' | 'queue' | 'exhibitors' | 'style-profiles' | 'setting
type SiteFilter = '' | '1' | '2';
const SITE_CONFIG = {
1: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
1: { name: 'AV Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]' },
};
@@ -289,7 +289,7 @@ export default function ShowCalendarPage() {
<Link href="/admin" className="text-[#555] hover:text-white text-sm transition-colors"> Dashboard</Link>
</div>
<h1 className="text-xl font-bold text-white">Show Calendar Engine</h1>
<p className="text-[#555] text-sm mt-0.5">Broadcast Beat + AV Beat Global Show Coverage</p>
<p className="text-[#555] text-sm mt-0.5">AV Beat + AV Beat Global Show Coverage</p>
</div>
<div className="flex items-center gap-3">
<button
@@ -320,7 +320,7 @@ export default function ShowCalendarPage() {
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
}`}
>
{s === '' ? 'All Sites' : s === '1' ? '🔵 Broadcast Beat' : '🟠 AV Beat'}
{s === '' ? 'All Sites' : s === '1' ? '🔵 AV Beat' : '🟠 AV Beat'}
</button>
))}
</div>
@@ -643,7 +643,7 @@ export default function ShowCalendarPage() {
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
<p className="text-white font-medium mb-1">Pen Name Rosters</p>
<p className="mb-2"><strong className="text-[#aaa]">Broadcast Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
<p className="mb-2"><strong className="text-[#aaa]">AV Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
<p><strong className="text-[#aaa]">AV Beat (7 writers):</strong> Rex Chandler, Dana Flux, Derek Wainwright, Sloane Rigging, Chip Crosspoint, Blair Presenter, Jordan Lumen</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
@@ -689,7 +689,7 @@ export default function ShowCalendarPage() {
onChange={e => setEditingEvent(prev => ({ ...prev, site_id: parseInt(e.target.value) }))}
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
>
<option value={1}>Broadcast Beat</option>
<option value={1}>AV Beat</option>
<option value={2}>AV Beat</option>
</select>
</div>

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import ImageAttachButton from '@/components/forum/ImageAttachButton';
import { createClient } from '@/lib/supabase/client';
interface ForumCategory {
@@ -336,6 +337,10 @@ export default function ForumCategoryPage() {
rows={5}
className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6] resize-none"
/>
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setNewBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{newError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{newError}

View File

@@ -0,0 +1,96 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { createClient } from "@/lib/supabase/client";
function ForgotInner() {
const supabase = createClient();
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true); setMsg(null); setErr(null);
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
redirectTo: `${window.location.origin}/forum/reset-password`,
});
setBusy(false);
if (error) {
setErr(error.message);
} else {
setMsg("Check your inbox — we sent a password reset link. If you didn't have an account, no email is sent.");
}
}
return (
<>
<Header />
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
<div className="max-w-md mx-auto px-4 py-16">
<div className="text-center mb-6">
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Account recovery</div>
<h1 className="text-3xl font-heading font-bold text-white">Reset your password</h1>
<p className="text-[#888] font-body text-sm mt-2">
Enter your email we&rsquo;ll send a link to set a new password.
</p>
</div>
<form
onSubmit={submit}
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
>
{err && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{err}
</div>
)}
{msg && (
<div className="bg-[#0a1f2a] border border-[#1e6c8c] text-[#7adbff] font-body text-sm px-3 py-2 rounded">
{msg}
</div>
)}
<div>
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
/>
</div>
<button
type="submit"
disabled={busy || !email.trim()}
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
>
{busy ? "Sending…" : "Send reset link"}
</button>
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
Remembered it?{" "}
<Link href="/forum/login" className="text-[#3b82f6] hover:underline font-semibold">
Back to sign in
</Link>
</div>
</form>
</div>
</main>
<Footer />
</>
);
}
export default function ForgotPasswordPage() {
return (
<Suspense fallback={null}>
<ForgotInner />
</Suspense>
);
}

View File

@@ -1,19 +1,19 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'The Crew Lounge — Broadcast Beat',
title: 'The Crew Lounge — AV Beat',
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Discuss live production, IP workflows, streaming, audio, cameras, AI automation, and more.',
alternates: { canonical: '/forum' },
openGraph: {
title: 'The Crew Lounge — Broadcast Beat',
title: 'The Crew Lounge — AV Beat',
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Ask questions, share expertise, and discuss broadcast technology.',
url: '/forum',
type: 'website',
},
twitter: {
card: 'summary',
title: 'The Crew Lounge — Broadcast Beat',
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide on Broadcast Beat.',
title: 'The Crew Lounge — AV Beat',
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide on AV Beat.',
},
};
@@ -21,19 +21,19 @@ export default function ForumLayout({ children }: { children: React.ReactNode })
const schema = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Broadcast Beat — The Crew Lounge',
name: 'AV Beat — The Crew Lounge',
description: 'The Crew Lounge: an industry community for broadcast, motion picture and post production professionals worldwide.',
url: 'https://broadcastbeat.com/forum',
url: 'https://avbeat.com/forum',
isPartOf: {
'@type': 'WebSite',
name: 'Broadcast Beat',
url: 'https://broadcastbeat.com',
name: 'AV Beat',
url: 'https://avbeat.com',
},
breadcrumb: {
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://broadcastbeat.com' },
{ '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://broadcastbeat.com/forum' },
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://avbeat.com' },
{ '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://avbeat.com/forum' },
],
},
};

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
import { createClient } from '@/lib/supabase/client';
function safeNext(next: string | null): string {
@@ -50,17 +51,18 @@ function ForumLoginInner() {
return (
<>
<Header />
<main className="min-h-screen bg-[#111111]">
<div className="max-w-md mx-auto px-4 py-12">
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
<div className="max-w-md mx-auto px-4 py-16">
<div className="text-center mb-6">
<h1 className="text-2xl font-heading font-bold text-white">Sign in to the Forum</h1>
<p className="text-[#888] font-body text-sm mt-1">
Use your Broadcast Beat account to post, reply, and vote.
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Welcome back</div>
<h1 className="text-3xl font-heading font-bold text-white">Sign in to AV Beat</h1>
<p className="text-[#888] font-body text-sm mt-2">
Your account covers the forum, newsletter preferences, and saved articles.
</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4"
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
>
{error && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
@@ -100,6 +102,15 @@ function ForumLoginInner() {
>
{loading ? 'Signing in…' : 'Sign In'}
</button>
<div className="text-right text-xs font-body">
<Link
href={`/forum/forgot-password?email=${encodeURIComponent(email)}`}
className="text-[#888] hover:text-[#3b82f6] hover:underline"
>
Forgot password?
</Link>
</div>
<SocialAuthButtons next={next} />
<div className="text-center text-xs font-body text-[#888] pt-2">
No account yet?{' '}
<Link

View File

@@ -629,7 +629,7 @@ export default function ForumIndexPage() {
<ul className="text-[#aab4c4] font-body text-sm space-y-1">
<li> Be respectful and professional this is a community of peers</li>
<li> Share real-world experience and cite sources when possible</li>
<li> The <span className="text-[#3b82f6]">Broadcast Beat AI</span> assistant is available in threads to help answer questions it is clearly labeled</li>
<li> The <span className="text-[#3b82f6]">AV Beat AI</span> assistant is available in threads to help answer questions it is clearly labeled</li>
<li> No spam, self-promotion, or vendor pitches without disclosure</li>
</ul>
</div>

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
import { createClient } from '@/lib/supabase/client';
function safeNext(next: string | null): string {
@@ -44,6 +45,34 @@ function ForumRegisterInner() {
if (password !== confirm) return setError('Passwords do not match.');
setLoading(true);
// Check first: if this email is already in our subscriber pool (existing
// newsletter reader), we send them a magic-link login instead of creating
// a new account — they already 'exist' to us. They can then set a
// password from /account if they want.
let knownSubscriber = false;
try {
const probe = await fetch(`/api/auth/lookup-email?email=${encodeURIComponent(email)}`);
if (probe.ok) {
const j = await probe.json();
knownSubscriber = !!j.known;
}
} catch { /* probe failure → fall through to signUp */ }
if (knownSubscriber) {
const { error: otpErr } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
shouldCreateUser: true,
data: { full_name: fullName.trim() },
},
});
setLoading(false);
if (otpErr) { setError(otpErr.message); return; }
setInfo("Welcome back! You're already on our list. We sent a one-time login link to your inbox — click it to sign in. You can set a password from your account afterwards.");
return;
}
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password,
@@ -72,18 +101,56 @@ function ForumRegisterInner() {
return (
<>
<Header />
<main className="min-h-screen bg-[#111111]">
<div className="max-w-md mx-auto px-4 py-12">
<div className="text-center mb-6">
<h1 className="text-2xl font-heading font-bold text-white">Create a Forum Account</h1>
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
<div className="max-w-5xl mx-auto px-4 py-12 grid grid-cols-1 lg:grid-cols-[1fr_440px] gap-10 items-start">
{/* Left rail — what membership gets you */}
<aside className="hidden lg:block">
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-3">Membership</div>
<h1 className="font-heading text-white text-4xl xl:text-5xl font-bold leading-tight mb-4">
Join AV Beat
</h1>
<p className="text-[#cbd5e1] text-base leading-relaxed mb-6 max-w-md">
One free account for the broadcast engineering community covering NAB, IBC, and the people behind every signal chain.
</p>
<ul className="space-y-3 text-sm text-[#d1d5db] max-w-md">
{[
["💬", "Join the broadcast engineering forum", "Threads, replies, and a verified industry community"],
["📧", "Manage your newsletter preferences", "Pick which categories — Commercial TV, Radio, Industry News, SMPTE — you want delivered"],
["🔖", "Save articles to read later", "Bookmark coverage, build your own reading list"],
["🔔", "Get notified of replies and follow-ups", "Email you when someone responds to a thread you're in"],
].map(([icon, title, desc]) => (
<li key={String(title)} className="flex gap-3 items-start">
<span className="text-xl leading-none flex-shrink-0">{icon}</span>
<div>
<div className="font-semibold text-white">{title}</div>
<div className="text-xs text-[#9ca3af] mt-0.5">{desc}</div>
</div>
</li>
))}
</ul>
<div className="mt-6 text-[11px] text-[#666] leading-relaxed max-w-md border-t border-[#252525] pt-4">
<strong className="text-[#888]">PR firms &amp; contributors:</strong> a separate contributor application is required to submit press releases.
{" "}
<Link href="/about/advertise" className="text-[#3b82f6] hover:underline">Contact us</Link> if you represent a manufacturer or agency.
</div>
</aside>
{/* Mobile-only hero (collapsed copy) */}
<div className="lg:hidden text-center mb-2">
<h1 className="font-heading text-white text-2xl font-bold">Join AV Beat</h1>
<p className="text-[#888] font-body text-sm mt-1">
Post threads, reply, vote, and follow the conversation.
One account for the forum, newsletter preferences, and saved articles.
</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4"
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
>
<div className="hidden lg:block mb-2">
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-1">Create your account</div>
<div className="text-[#888] text-xs">Takes 30 seconds. Free, no card needed.</div>
</div>
{error && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{error}
@@ -150,15 +217,23 @@ function ForumRegisterInner() {
<button
type="submit"
disabled={loading}
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
>
{loading ? 'Creating account…' : 'Create Account'}
{loading ? 'Creating account…' : 'Create Free Account'}
</button>
<div className="text-center text-xs font-body text-[#888] pt-2">
<p className="text-[10px] text-[#666] leading-relaxed text-center px-2">
By creating an account you agree to our{' '}
<Link href="/terms" className="text-[#888] hover:text-[#3b82f6] underline">terms</Link>
{' '}and{' '}
<Link href="/privacy" className="text-[#888] hover:text-[#3b82f6] underline">privacy policy</Link>.
We&rsquo;ll never sell your email.
</p>
<SocialAuthButtons next={next} />
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
Already have an account?{' '}
<Link
href={`/forum/login?next=${encodeURIComponent(next)}`}
className="text-[#3b82f6] hover:underline"
className="text-[#3b82f6] hover:underline font-semibold"
>
Sign in
</Link>

View File

@@ -0,0 +1,121 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { createClient } from "@/lib/supabase/client";
function ResetInner() {
const router = useRouter();
const supabase = createClient();
const [hasSession, setHasSession] = useState<boolean | null>(null);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
// The recovery flow lands here with a fresh session (Supabase verifies
// the token in the URL hash and signs the user in). Confirm we got it.
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setHasSession(!!data.session);
});
}, [supabase]);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true); setMsg(null); setErr(null);
if (password.length < 8) { setErr("Use at least 8 characters."); setBusy(false); return; }
if (password !== confirm) { setErr("Passwords don't match."); setBusy(false); return; }
const { error } = await supabase.auth.updateUser({ password });
setBusy(false);
if (error) { setErr(error.message); return; }
setMsg("Password updated. Redirecting…");
setTimeout(() => router.push("/forum"), 1200);
}
return (
<>
<Header />
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
<div className="max-w-md mx-auto px-4 py-16">
<div className="text-center mb-6">
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Reset password</div>
<h1 className="text-3xl font-heading font-bold text-white">Set a new password</h1>
</div>
{hasSession === false ? (
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 text-center">
<p className="text-[#cbd5e1] mb-4 text-sm">
This reset link is expired or invalid.
</p>
<Link
href="/forum/forgot-password"
className="inline-block bg-[#3b82f6] hover:bg-[#2563eb] text-white font-semibold text-sm px-5 py-2 rounded"
>
Request a new link
</Link>
</div>
) : (
<form
onSubmit={submit}
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
>
{err && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{err}
</div>
)}
{msg && (
<div className="bg-[#0a1f2a] border border-[#1e6c8c] text-[#7adbff] font-body text-sm px-3 py-2 rounded">
{msg}
</div>
)}
<div>
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
New password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
/>
</div>
<div>
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
Confirm password
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
/>
</div>
<button
type="submit"
disabled={busy}
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-semibold text-sm px-5 py-3 rounded-lg"
>
{busy ? "Saving…" : "Update password"}
</button>
</form>
)}
</div>
</main>
<Footer />
</>
);
}
export default function ResetPasswordPage() {
return <Suspense fallback={null}><ResetInner /></Suspense>;
}

View File

@@ -9,6 +9,8 @@ import { pickAds, rotateAll } from '@/lib/ads';
import { createClient } from '@/lib/supabase/client';
import SidebarAdStack from "@/components/SidebarAdStack";
import LinkifyPlainText from "@/components/LinkifyPlainText";
import RichBody from "@/components/forum/RichBody";
import ImageAttachButton from "@/components/forum/ImageAttachButton";
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
// Inline ad slot inserted between forum replies every N posts.
@@ -371,7 +373,7 @@ export default function ForumThreadPage() {
<span className="text-[#555] font-body text-xs">{thread.view_count} views</span>
</div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<LinkifyPlainText body={thread.body} />
<RichBody body={thread.body} />
</p>
<VoteButtons
targetType="thread"
@@ -413,7 +415,7 @@ export default function ForumThreadPage() {
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
</div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<LinkifyPlainText body={reply.body} />
<RichBody body={reply.body} />
</p>
<VoteButtons
targetType="reply"
@@ -445,7 +447,7 @@ export default function ForumThreadPage() {
<span className="text-[#3b82f6] text-xs font-bold">AI</span>
</div>
<div>
<span className="text-white font-body font-semibold text-sm">Broadcast Beat AI Assistant</span>
<span className="text-white font-body font-semibold text-sm">AV Beat AI Assistant</span>
<span className="ml-2 text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/30 px-2 py-0.5 rounded font-body">AI Not a human</span>
</div>
</div>
@@ -495,6 +497,10 @@ export default function ForumThreadPage() {
rows={5}
className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6] resize-none"
/>
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setReplyBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{replyError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{replyError}

View File

@@ -12,11 +12,11 @@ import SidebarAdStack from "@/components/SidebarAdStack";
export const revalidate = 1800;
export const metadata: Metadata = {
title: "Production Gear & Reviews — Broadcast Beat",
title: "Production Gear & Reviews — AV Beat",
description: "In-depth reviews and coverage of broadcast cameras, audio gear, production equipment, and professional media technology.",
alternates: { canonical: "/gear" },
openGraph: {
title: "Production Gear & Reviews — Broadcast Beat",
title: "Production Gear & Reviews — AV Beat",
description: "In-depth reviews and coverage of broadcast cameras, audio gear, production equipment, and professional media technology.",
url: "/gear",
type: "website",

View File

@@ -16,7 +16,7 @@ export default function GlobalError({
<body style={{ background: "#0d0d0d", color: "#e0e0e0", fontFamily: "system-ui", padding: 24, minHeight: "100vh" }}>
<div style={{ maxWidth: 480, margin: "10vh auto", textAlign: "center" }}>
<h2 style={{ fontSize: 22, marginBottom: 12 }}>Something went wrong</h2>
<p style={{ marginBottom: 12, color: "#888" }}>Broadcast Beat hit an unexpected error.</p>
<p style={{ marginBottom: 12, color: "#888" }}>AV Beat hit an unexpected error.</p>
{error?.digest && (
<p style={{ fontSize: 12, opacity: 0.5, marginBottom: 16, fontFamily: "monospace" }}>ref: {error.digest}</p>
)}

View File

@@ -664,7 +664,7 @@ export default function ArticleFeed() {
{/* Inline social share row */}
{(() => {
const shareUrl = `https://broadcastbeat.com${articleHref.startsWith('/') ? articleHref : `/articles/${article?.slug}`}`;
const shareUrl = `https://avbeat.com${articleHref.startsWith('/') ? articleHref : `/articles/${article?.slug}`}`;
const shareTitle = article?.title || '';
return (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-[#1a1a1a]">

View File

@@ -9,7 +9,7 @@ const hero = {
title: "Sony BRC-AM7 + HXC-FZ90: Better Together in the Field",
category: "BROADCAST",
excerpt: "Sony's latest camera pairing delivers unprecedented flexibility for field production teams, combining 4K HDR capture with compact form factors designed for the realities of live broadcast.",
image: "https://www.broadcastbeat.com/wp-content/uploads/BRC-AM7_012-Mid.png",
image: "https://www.avbeat.com/wp-content/uploads/BRC-AM7_012-Mid.png",
alt: "Sony BRC-AM7 broadcast camera in field setting",
slug: "sony-brc-am7-hxc-fz90",
};
@@ -19,28 +19,28 @@ const rotatingStories = [
{
title: "Sony\'s BVM-HX1710: The Compact Powerhouse Changing How Broadcast & Post See Their Work",
category: "POST PRODUCTION",
image: "https://www.broadcastbeat.com/wp-content/uploads/bvmhx1710_3q_250131_02-copy-scaled.png",
image: "https://www.avbeat.com/wp-content/uploads/bvmhx1710_3q_250131_02-copy-scaled.png",
alt: "Sony BVM-HX1710 broadcast monitor three-quarter view",
slug: "sony-bvm-hx1710",
},
{
title: "The Top 5 Challenges in MCRs & NOCs—And How to Stay Ahead",
category: "BROADCAST",
image: "https://www.broadcastbeat.com/wp-content/uploads/data-insight-ip-monitoring-1024x597.pngw3_.webp",
image: "https://www.avbeat.com/wp-content/uploads/data-insight-ip-monitoring-1024x597.pngw3_.webp",
alt: "IP monitoring dashboard showing data insights for MCR operations",
slug: "top-5-challenges-mcrs-nocs",
},
{
title: "TESSERA SQ200: THE NEW PACE-SETTER IN LED PROCESSING",
category: "FEATURED",
image: "https://www.broadcastbeat.com/wp-content/uploads/Hero-Image_SQ200-Gen-3_Sep2025_CMYK.jpg",
image: "https://www.avbeat.com/wp-content/uploads/Hero-Image_SQ200-Gen-3_Sep2025_CMYK.jpg",
alt: "Tessera SQ200 Gen 3 LED processor hero image",
slug: "tessera-sq200-led-processing",
},
{
title: "Backlight adds real-time collaboration to Iconik, AI-powered livestreams to Wildmoka at IBC2025",
category: "BROADCAST",
image: "https://www.broadcastbeat.com/wp-content/uploads/Iconik-scaled.jpeg",
image: "https://www.avbeat.com/wp-content/uploads/Iconik-scaled.jpeg",
alt: "Iconik media asset management platform interface",
slug: "backlight-iconik-wildmoka-ibc2025",
},

View File

@@ -8,7 +8,7 @@ const spotlights = [
{
name: "Vizrt",
title: "From Start-Up to Global Powerhouse in Video in Just Two Decades",
image: "https://www.broadcastbeat.com/wp-content/uploads/michael-320x220.jpg",
image: "https://www.avbeat.com/wp-content/uploads/michael-320x220.jpg",
alt: "Vizrt company spotlight profile image",
tag: "Company",
href: "/news"
@@ -16,7 +16,7 @@ const spotlights = [
{
name: "Scott Freeman",
title: "The Sherlock Holmes of Video Post-Production",
image: "https://www.broadcastbeat.com/wp-content/uploads/scott-freeman.jpg",
image: "https://www.avbeat.com/wp-content/uploads/scott-freeman.jpg",
alt: "Scott Freeman video post-production professional headshot",
tag: "Profile",
href: "/news"
@@ -24,7 +24,7 @@ const spotlights = [
{
name: "Al Roker",
title: "A Weatherman Who Wears Many Hats",
image: "https://www.broadcastbeat.com/wp-content/uploads/al-roker2-320x220.jpg",
image: "https://www.avbeat.com/wp-content/uploads/al-roker2-320x220.jpg",
alt: "Al Roker television weatherman and broadcaster portrait",
tag: "Profile",
href: "/news"
@@ -32,7 +32,7 @@ const spotlights = [
{
name: "Michael Kammes",
title: "Shaping the Best Workflow for Media Organizations",
image: "https://www.broadcastbeat.com/wp-content/uploads/kammes-2-320x220.jpg",
image: "https://www.avbeat.com/wp-content/uploads/kammes-2-320x220.jpg",
alt: "Michael Kammes media workflow specialist headshot",
tag: "Profile",
href: "/news"
@@ -40,7 +40,7 @@ const spotlights = [
{
name: "Gary Trenda",
title: "Top Frequency \'Traffic Cop\' for Wireless Microphones",
image: "https://www.broadcastbeat.com/wp-content/uploads/gary-320x220.jpg",
image: "https://www.avbeat.com/wp-content/uploads/gary-320x220.jpg",
alt: "Gary Trenda wireless microphone frequency coordinator portrait",
tag: "Profile",
href: "/news"
@@ -48,7 +48,7 @@ const spotlights = [
{
name: "Jim Anderson & Ulrike Schwarz",
title: "Partners in Immersive Sound",
image: "https://www.broadcastbeat.com/wp-content/uploads/urike-320x220.jpg",
image: "https://www.avbeat.com/wp-content/uploads/urike-320x220.jpg",
alt: "Jim Anderson and Ulrike Schwarz immersive sound designers",
tag: "Profile",
href: "/news"
@@ -56,7 +56,7 @@ const spotlights = [
{
name: "Sound Effects",
title: "Creating Cutting-Edge New Sound Effects — Film by Film",
image: "https://www.broadcastbeat.com/wp-content/uploads/mark-spot-320x215.jpg",
image: "https://www.avbeat.com/wp-content/uploads/mark-spot-320x215.jpg",
alt: "Sound effects creation for film and broadcast production",
tag: "Feature",
href: "/gear"
@@ -64,7 +64,7 @@ const spotlights = [
{
name: "Libby Casey",
title: "Pioneering Television Broadcasting at the Washington Post",
image: "https://www.broadcastbeat.com/wp-content/uploads/spotlight-320x215.jpg",
image: "https://www.avbeat.com/wp-content/uploads/spotlight-320x215.jpg",
alt: "Libby Casey television broadcaster Washington Post studio",
tag: "Profile",
href: "/news"
@@ -72,7 +72,7 @@ const spotlights = [
{
name: "NBCU Academy",
title: "Preparing Students for Careers in News, Technology and Broadcast",
image: "https://www.broadcastbeat.com/wp-content/uploads/nbcu-spotlight-320x215.jpg",
image: "https://www.avbeat.com/wp-content/uploads/nbcu-spotlight-320x215.jpg",
alt: "NBCU Academy students learning broadcast journalism and technology",
tag: "Education",
href: "/about"
@@ -80,7 +80,7 @@ const spotlights = [
{
name: "BirdDog",
title: "Simplifying Global Television Production",
image: "https://www.broadcastbeat.com/wp-content/uploads/bird-sptfeat.jpg",
image: "https://www.avbeat.com/wp-content/uploads/bird-sptfeat.jpg",
alt: "BirdDog broadcast production technology company spotlight",
tag: "Company",
href: "/news"
@@ -88,7 +88,7 @@ const spotlights = [
{
name: "Michael Cioni",
title: "Speeding Digital Cinema with Camera to Cloud",
image: "https://www.broadcastbeat.com/wp-content/uploads/michael-copy-320x220.jpg",
image: "https://www.avbeat.com/wp-content/uploads/michael-copy-320x220.jpg",
alt: "Michael Cioni digital cinema and camera to cloud technology expert",
tag: "Profile",
href: "/technology"
@@ -96,7 +96,7 @@ const spotlights = [
{
name: "Jeff Greenberg",
title: "Finding Workarounds for Gaps in the Video Production Workflow",
image: "https://www.broadcastbeat.com/wp-content/uploads/greenberg-320x215.jpg",
image: "https://www.avbeat.com/wp-content/uploads/greenberg-320x215.jpg",
alt: "Jeff Greenberg video production workflow consultant headshot",
tag: "Profile",
href: "/technology"

View File

@@ -13,13 +13,13 @@ import SponsorLogoStrip from "@/components/SponsorLogoStrip";
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
export const metadata: Metadata = {
title: 'Broadcast Beat — Broadcast Engineering News & Insights',
title: 'AV Beat — Broadcast Engineering News & Insights',
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
alternates: {
canonical: '/',
},
openGraph: {
title: 'Broadcast Beat — Broadcast News',
title: 'AV Beat — Broadcast News',
description: 'Breaking news and insights for broadcast engineering professionals.',
url: '/',
type: 'website',
@@ -28,13 +28,13 @@ export const metadata: Metadata = {
url: '/assets/images/og-image.png',
width: 1200,
height: 630,
alt: 'Broadcast Beat — Broadcast Engineering News & Insights',
alt: 'AV Beat — Broadcast Engineering News & Insights',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Broadcast Beat — Broadcast News',
title: 'AV Beat — Broadcast News',
description: 'Breaking news and insights for broadcast engineering professionals.',
images: ['/assets/images/og-image.png'],
},
@@ -138,7 +138,7 @@ export default function HomePage() {
{/* H1 Hero Heading */}
<div className="sr-only">
<h1>Broadcast Beat Broadcast Engineering News & Insights</h1>
<h1>AV Beat Broadcast Engineering News & Insights</h1>
</div>
{/* Featured: cinematic hero + 4-up rail below (FeaturedBentoFromDb) */}

View File

@@ -1,10 +1,11 @@
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-static";
export const dynamic = "force-dynamic";
export const revalidate = 0;
/**
* Image proxy: serves files from Supabase Storage under a clean broadcastbeat.com
* Image proxy: serves files from Supabase Storage under a clean avbeat.com
* URL so article HTML doesn't ship the long `https://supabase.onsethost.com/
* storage/v1/object/public/...` path. Better for SEO + makes images look
* first-party.
@@ -13,24 +14,59 @@ export const dynamic = "force-static";
* /img/campaign-creatives/<uuid>/<file>.jpg
* /img/bb-forum-avatars/<file>.png
*
* Path segments are forwarded directly to the matching public bucket.
* Resilience rules (prior bug):
* - Supabase storage occasionally returns transient 5xx. We retry up to 2x
* with backoff before surfacing failure.
* - Failures MUST emit no-store cache headers, otherwise Next + Cloudflare
* pin the failure indefinitely (page itself had s-maxage of one year).
* - The route must be dynamic — force-static used to pre-bake a single
* fetch result at build time, which is exactly the bug we hit.
*/
const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com")
.replace(/\/+$/, "") + "/storage/v1/object/public";
async function fetchWithRetry(url: string, tries = 3): Promise<Response> {
let lastErr: unknown = null;
for (let i = 0; i < tries; i++) {
try {
const r = await fetch(url, { cache: "no-store" });
if (r.ok) return r;
if (r.status === 404) return r;
// 5xx — back off and retry
lastErr = new Error(`upstream ${r.status}`);
} catch (e) {
lastErr = e;
}
if (i < tries - 1) await new Promise((res) => setTimeout(res, 150 * (i + 1)));
}
throw lastErr instanceof Error ? lastErr : new Error("upstream failed");
}
function failResponse(status: number, body: string) {
return new Response(body, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
// Critical: never cache failures.
"cache-control": "no-store, max-age=0",
"x-bb-img-source": "proxy-error",
},
});
}
export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
if (!path || path.length < 2) return new Response("bad path", { status: 400 });
if (!path || path.length < 2) return failResponse(400, "bad path");
const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`;
let upstream: Response;
try {
upstream = await fetch(target, { cache: "force-cache" });
upstream = await fetchWithRetry(target, 3);
} catch (e: any) {
return new Response("upstream error: " + (e?.message || String(e)), { status: 502 });
return failResponse(502, "upstream error after retries: " + (e?.message || String(e)));
}
if (!upstream.ok) {
return new Response("not found", { status: upstream.status === 404 ? 404 : 502 });
return failResponse(upstream.status === 404 ? 404 : 502, "not found");
}
return new Response(upstream.body, {

View File

@@ -34,7 +34,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
title: 'Broadcast Beat — Broadcast Engineering News & Insights',
title: 'AV Beat — Broadcast Engineering News & Insights',
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
icons: {
icon: [
@@ -58,24 +58,24 @@ export const metadata: Metadata = {
type: 'website',
locale: 'en_US',
url: '/',
siteName: 'Broadcast Beat',
title: 'Broadcast Beat — Broadcast News',
siteName: 'AV Beat',
title: 'AV Beat — Broadcast News',
description: 'Breaking news and insights for broadcast engineering professionals.',
images: [
{
url: '/assets/images/og-image.png',
width: 1200,
height: 630,
alt: 'Broadcast Beat — Broadcast Engineering News & Insights',
alt: 'AV Beat — Broadcast Engineering News & Insights',
type: 'image/png'
}]
},
twitter: {
card: 'summary_large_image',
site: '@Broadcast Beat',
creator: '@Broadcast Beat',
title: 'Broadcast Beat — Broadcast News',
site: '@AV Beat',
creator: '@AV Beat',
title: 'AV Beat — Broadcast News',
description: 'Breaking news and insights for broadcast engineering professionals.',
images: ['/assets/images/og-image.png']
},
@@ -122,9 +122,9 @@ export default function RootLayout({
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Broadcast Beat',
url: 'https://broadcastbeat.com',
logo: "https://broadcastbeat.com/assets/images/logo.png",
name: 'AV Beat',
url: 'https://avbeat.com',
logo: "https://avbeat.com/assets/images/logo.png",
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
sameAs: [
'https://linkedin.com',
@@ -135,7 +135,7 @@ export default function RootLayout({
contactPoint: {
'@type': 'ContactPoint',
contactType: 'Editorial',
url: 'https://broadcastbeat.com'
url: 'https://avbeat.com'
}
})
}} />

View File

@@ -53,7 +53,7 @@ function LoginInner() {
<main className="min-h-screen bg-[#111111]">
<div className="max-w-md mx-auto px-4 py-12">
<div className="text-center mb-6">
<h1 className="text-2xl font-heading font-bold text-white">Sign in to Broadcast Beat</h1>
<h1 className="text-2xl font-heading font-bold text-white">Sign in to AV Beat</h1>
<p className="text-[#888] font-body text-sm mt-1">
Editor and admin access. Forum members can also sign in here.
</p>

View File

@@ -105,13 +105,13 @@ export async function generateMetadata({
const { slug } = await params;
const result = await loadManufacturer(slug);
if (!result) {
return { title: "Manufacturer not found — Broadcast Beat" };
return { title: "Manufacturer not found — AV Beat" };
}
const { company } = result;
const title = `${company.company_name} — Manufacturer Profile | Broadcast Beat`;
const title = `${company.company_name} — Manufacturer Profile | AV Beat`;
const description =
(company.bio || "").slice(0, 200) ||
`${company.company_name} is a broadcast industry manufacturer covered by Broadcast Beat.`;
`${company.company_name} is a broadcast industry manufacturer covered by AV Beat.`;
return {
title,
description,
@@ -119,7 +119,7 @@ export async function generateMetadata({
openGraph: {
title,
description,
url: `https://broadcastbeat.com/manufacturers/${slug}`,
url: `https://avbeat.com/manufacturers/${slug}`,
type: "profile",
...(company.logo_url ? { images: [{ url: company.logo_url }] } : {}),
},
@@ -466,7 +466,7 @@ export default async function ManufacturerProfile({
)}
<footer className="text-xs text-[#666] mt-12 pt-6 border-t border-[#252525]">
Listing maintained by Broadcast Beat editorial.
Listing maintained by AV Beat editorial.
{company.last_mentioned && (
<> Last mentioned in coverage on {new Date(company.last_mentioned).toISOString().slice(0, 10)}.</>
)}

View File

@@ -8,16 +8,16 @@ import ManufacturerDirectoryClient from "./DirectoryClient";
export const revalidate = 1800;
export const metadata: Metadata = {
title: "Production Industry Manufacturers — Broadcast Beat",
title: "Production Industry Manufacturers — AV Beat",
description:
"Browse 1,000+ broadcast, post-production, and live-production manufacturers exhibiting at NAB Show and IBC. Find vendor profiles, booth numbers, product categories, and the latest news from each company.",
alternates: { canonical: "/manufacturers" },
openGraph: {
title: "Production Industry Manufacturers — Broadcast Beat",
title: "Production Industry Manufacturers — AV Beat",
description:
"Comprehensive directory of broadcast industry manufacturers, exhibitors, and vendors.",
type: "website",
url: "https://broadcastbeat.com/manufacturers",
url: "https://avbeat.com/manufacturers",
},
};
@@ -97,12 +97,12 @@ export default async function ManufacturersIndex() {
<section className="mt-16 border-t border-[#252525] pt-8 text-sm text-[#888] leading-relaxed">
<p>
Sources: 2026 NAB Show exhibitor list (Map Your Show), IBC exhibitor list,
manufacturer-direct press releases, and reporting tracked on Broadcast Beat.
manufacturer-direct press releases, and reporting tracked on AV Beat.
New manufacturers and updates land here as our crawlers detect them.
</p>
<p className="mt-2">
Are you a manufacturer and want to update your listing?{" "}
<a href="mailto:editors@broadcastbeat.com?subject=Manufacturer%20Directory%20Update" className="text-[#3b82f6] hover:underline">
<a href="mailto:editors@avbeat.com?subject=Manufacturer%20Directory%20Update" className="text-[#3b82f6] hover:underline">
Contact our editors
</a>
.

View File

@@ -3,7 +3,7 @@ import Header from '@/components/Header';
import Footer from '@/components/Footer';
export const metadata = {
title: 'Billing | Broadcast Beat Marketplace',
title: 'Billing | AV Beat Marketplace',
};
export default function BillingPage() {

View File

@@ -64,7 +64,7 @@ export default function GigsPage() {
<div className="text-center py-20 text-gray-500">
<div className="text-4xl mb-4">🎬</div>
<p className="text-lg font-semibold mb-2">No gigs posted yet</p>
<p className="text-sm">Be the first to post a crew call on Broadcast Beat Marketplace.</p>
<p className="text-sm">Be the first to post a crew call on AV Beat Marketplace.</p>
</div>
) : (
<div className="space-y-4">

View File

@@ -64,7 +64,7 @@ export default function JobsPage() {
<div className="text-center py-20 text-gray-500">
<div className="text-4xl mb-4">📋</div>
<p className="text-lg font-semibold mb-2">No jobs posted yet</p>
<p className="text-sm">Be the first to post a job on Broadcast Beat Marketplace.</p>
<p className="text-sm">Be the first to post a job on AV Beat Marketplace.</p>
</div>
) : (
<div className="space-y-4">

View File

@@ -3,7 +3,7 @@ import Header from '@/components/Header';
import Footer from '@/components/Footer';
export const metadata = {
title: 'Production Crew & Vendor Marketplace | Broadcast Beat',
title: 'Production Crew & Vendor Marketplace | AV Beat',
description:
'Find broadcast and production crew, vendors, jobs, and gig opportunities. The professional marketplace built for the broadcast industry.',
};

View File

@@ -9,16 +9,16 @@ import CompanyMentionsHover from "@/components/CompanyMentionsHover";
export const revalidate = 600;
export const metadata: Metadata = {
title: "2026 NAB Show — Live Coverage Hub | Broadcast Beat",
title: "2026 NAB Show — Live Coverage Hub | AV Beat",
description:
"Broadcast Beat's live coverage of 2026 NAB Show: 1,100+ exhibitors, the latest product launches, booth-by-booth news, and editorial pick stories from the show floor.",
"AV Beat's live coverage of 2026 NAB Show: 1,100+ exhibitors, the latest product launches, booth-by-booth news, and editorial pick stories from the show floor.",
alternates: { canonical: "/nab-2026" },
openGraph: {
title: "2026 NAB Show — Live Coverage Hub",
description:
"Live coverage, exhibitor list, and breaking product news from 2026 NAB Show.",
type: "website",
url: "https://broadcastbeat.com/nab-2026",
url: "https://avbeat.com/nab-2026",
},
};
@@ -62,7 +62,7 @@ export default async function NabHub() {
const { data: ads } = await adv
.from("active_advertisers_by_property")
.select("company_name_lower")
.eq("property", "broadcastbeat");
.eq("property", "avbeat");
sponsors = new Set((ads || []).map((r: any) => String(r.company_name_lower)));
} catch { /* tolerate */ }
@@ -105,7 +105,7 @@ export default async function NabHub() {
</h1>
<p className="text-[#aaa] text-base max-w-3xl">
{exhibitors.length.toLocaleString()} exhibitors confirmed.
{sponsorRows.length > 0 && <> {sponsorRows.length} are Broadcast Beat content partners.</>}
{sponsorRows.length > 0 && <> {sponsorRows.length} are AV Beat content partners.</>}
{" "}Browse the directory, read the latest product news, and find every booth.
</p>
</div>

View File

@@ -6,9 +6,9 @@ export const revalidate = 600; // 10 min — Google rechecks frequently
// Google News sitemap. Only original-editorial articles (ai_rewritten_at IS
// NOT NULL or rewritten-articles surface) from the last 48 hours so we
// stay inside the GN window. Standard sitemap.xml covers older pages.
const PUBLICATION_NAME = 'Broadcast Beat';
const PUBLICATION_NAME = 'AV Beat';
const PUBLICATION_LANG = 'en';
const HOST = 'https://broadcastbeat.com';
const HOST = 'https://avbeat.com';
function esc(s: string): string {
return (s || '').replace(/[&<>"']/g, (c) =>

Some files were not shown because too many files have changed in this diff Show More