Compare commits
13 Commits
a045d6c558
...
archive/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43f78b161 | ||
|
|
dd93a74bdf | ||
|
|
c9b85b79b7 | ||
|
|
d67a2bd48d | ||
|
|
6a48473905 | ||
|
|
6b9251ca9b | ||
|
|
e6095dd803 | ||
|
|
445a11a1ee | ||
|
|
891f2dd341 | ||
|
|
0ede78fd35 | ||
|
|
39e9777e0a | ||
|
|
e24d78cd15 | ||
|
|
976c979e54 |
@@ -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).
|
||||
|
||||
@@ -29,6 +29,6 @@ export const imageHosts = [
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.broadcastbeat.com',
|
||||
hostname: 'www.avbeat.com',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "broadcastbeat",
|
||||
"name": "avbeat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"rocketCritical": {
|
||||
|
||||
BIN
public/assets/images/personas/chloe-delgado.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/assets/images/personas/darren-okafor.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/assets/images/personas/elena-vasquez.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/assets/images/personas/jeff-victor.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/assets/images/personas/marcus-halverson.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/assets/images/personas/mike-trayton.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/assets/images/personas/priya-rao.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/assets/images/personas/riley-park.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/assets/images/personas/ryan-salazar.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/assets/images/personas/sarah-quinn.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
13
public/assets/logos/av.svg
Normal 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 |
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · 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 & 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 & 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>
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]'}`}>
|
||||
|
||||
@@ -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: '15–25/day' },
|
||||
'broadcast-beat': { label: 'AV Beat', color: 'text-blue-400', target: '15–25/day' },
|
||||
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '10–15/day' },
|
||||
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '10–15/day' },
|
||||
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '10–15/day' },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 — ${siteUrl}</p>
|
||||
<p style="margin:0;color:#555;font-size:12px;">AV Beat — ${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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
51
src/app/api/auth/lookup-email/route.ts
Normal 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 });
|
||||
}
|
||||
72
src/app/api/comments/[id]/vote/route.ts
Normal 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 });
|
||||
}
|
||||
100
src/app/api/comments/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
119
src/app/api/upload/image/route.ts
Normal 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  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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 20 MB</span>
|
||||
</div>
|
||||
{newError && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{newError}
|
||||
|
||||
96
src/app/forum/forgot-password/page.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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’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>
|
||||
|
||||
121
src/app/forum/reset-password/page.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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 > 20 MB</span>
|
||||
</div>
|
||||
{replyError && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{replyError}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
}} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}.</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||