10 Commits

Author SHA1 Message Date
Ryan Salazar
d67a2bd48d AV Beat rebrand: theme swap (orange #E67E22 / near-black), logo, schema default → avb
- public/assets/images/logo.png + public/brand/logo*.png replaced with the 1320x310 AV Beat logo
- public/assets/logos/avbeat.png added as the canonical source
- tailwind.config.js + src/styles/tailwind.css + src/styles/redesign-tokens.css palette swapped (primary #0F0E0E, secondary #1D1A1A, accent #E67E22 orange, foreground #F0EBE6 warm white)
- Layout / robots / sitemap / home-page / about / contact / press-kit / team / global-error metadata switched to AV Beat (Where AV Meets IT)
- Bulk replaced 'Broadcast Beat'/'BroadcastBeat.com'/'broadcastbeat.com' across user-facing news/forum/marketplace/advertise/search/newsletter pages
- src/lib/supabase/{admin,server,client} now default to schema 'avb' (was 'bb') — overridable via NEXT_PUBLIC_SUPABASE_SCHEMA
- package.json renamed to 'avbeat'
- Single d60701 reference in about/team swapped to E67E22

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 286 KiB

View File

@@ -2,13 +2,13 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import SidebarAdStack from "@/components/SidebarAdStack"; import SidebarAdStack from "@/components/SidebarAdStack";
export const metadata = { title: "Contact — Broadcast Beat" }; export const metadata = { title: "Contact — AV Beat" };
const CONTACTS = [ const CONTACTS = [
{ {
title: "Editorial", title: "Editorial",
body: ( 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.", hint: "Story tips, corrections, interview requests.",
}, },
@@ -26,7 +26,7 @@ const CONTACTS = [
title: "Advertising", title: "Advertising",
body: ( 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>. <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">media kit</a>.
</> </>
), ),
@@ -52,8 +52,8 @@ export default function ContactPage() {
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10"> <div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main> <main>
<header className="mb-8 border-b border-[#252525] pb-6"> <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"> <div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#E67E22)] mb-2">
Broadcast Beat AV Beat · Where AV Meets IT
</div> </div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1> <h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1>
<p className="mt-3 text-[#9ca3af] text-base"> <p className="mt-3 text-[#9ca3af] text-base">

View File

@@ -3,8 +3,8 @@ import Footer from "@/components/Footer";
import SidebarAdStack from "@/components/SidebarAdStack"; import SidebarAdStack from "@/components/SidebarAdStack";
export const metadata = { export const metadata = {
title: "About Broadcast Beat", title: "About AV Beat",
description: "Broadcast Beat is an independent industry publication covering broadcast, production, and post-production technology.", description: "AV Beat is an independent industry publication covering the convergence of pro AV and IT — broadcast, live production, streaming, and post-production technology.",
}; };
export default function AboutPage() { export default function AboutPage() {
@@ -15,12 +15,12 @@ export default function AboutPage() {
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10"> <div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main> <main>
<header className="mb-8 border-b border-[#252525] pb-6"> <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"> <div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#E67E22)] mb-2">
Broadcast Beat AV Beat · Where AV Meets IT
</div> </div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">About</h1> <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"> <p className="mt-3 text-[#9ca3af] text-base max-w-2xl">
An independent trade publication covering broadcast, live production, streaming, and post-production technology. An independent trade publication covering the convergence of pro AV and IT broadcast, live production, streaming, and post-production technology.
</p> </p>
</header> </header>
@@ -32,11 +32,11 @@ export default function AboutPage() {
<section className="space-y-6 text-[#d1d5db] text-[15px] leading-relaxed"> <section className="space-y-6 text-[#d1d5db] text-[15px] leading-relaxed">
<Block title="What we cover"> <Block title="What we cover">
Broadcast Beat reports on the equipment, services, and people AV Beat reports on the equipment, services, and people that
that move the professional video industry forward. We follow move the professional AV and IT-converged video industry
product launches, executive moves, acquisitions, and the live forward. We follow product launches, executive moves,
production / IP / cloud workflows reshaping how content gets acquisitions, and the live production / IP / cloud workflows
made and delivered. reshaping how content gets made and delivered.
</Block> </Block>
<Block title="Where we go"> <Block title="Where we go">
@@ -57,9 +57,9 @@ export default function AboutPage() {
</Block> </Block>
<Block title="Get involved"> <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,#E67E22)] 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 advertising: <a className="text-[var(--color-text-info,#E67E22)] 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>. {" "}For our team: <a className="text-[var(--color-text-info,#E67E22)] hover:underline" href="/about/team">meet the editorial team</a>.
</Block> </Block>
</section> </section>
</main> </main>

View File

@@ -1,7 +1,7 @@
import Header from "@/components/Header"; import Header from "@/components/Header";
import Footer from "@/components/Footer"; 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() { export default function PressKitPage() {
return ( return (
@@ -13,23 +13,23 @@ export default function PressKitPage() {
<section> <section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Logos</h2> <h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Logos</h2>
<ul className="space-y-1"> <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,#E67E22)] hover:underline" href="/assets/images/logo.png" download>AV Beat logo (PNG)</a></li>
</ul> </ul>
</section> </section>
<section> <section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Boilerplate</h2> <h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Boilerplate</h2>
<p className="text-[#d1d5db] leading-relaxed"> <p className="text-[#d1d5db] leading-relaxed">
Broadcast Beat is an independent trade publication covering broadcast, AV Beat is an independent trade publication covering the convergence
live production, streaming, and post-production technology. Founded of pro AV and IT broadcast, live production, streaming, and
to bring engineering-rigor reporting to industry coverage, Broadcast Beat post-production technology. Founded to bring engineering-rigor
publishes daily on the equipment, services, and people that move reporting to industry coverage, AV Beat publishes daily on the
professional video forward. equipment, services, and people moving professional video forward.
</p> </p>
</section> </section>
<section> <section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Media contact</h2> <h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Media contact</h2>
<p> <p>
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a> <a className="text-[var(--color-text-info,#E67E22)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
</p> </p>
</section> </section>
</div> </div>

View File

@@ -27,6 +27,7 @@ interface OpsPersona {
email: string | null; email: string | null;
phone: string | null; phone: string | null;
extension: string | null; extension: string | null;
avatar_url: string | null;
} }
export default async function TeamPage() { export default async function TeamPage() {
@@ -60,9 +61,9 @@ export default async function TeamPage() {
); );
const { data: opsData } = await sbOps const { data: opsData } = await sbOps
.from("personas") .from("personas")
.select("id, display_name, role, email, phone, extension") .select("id, display_name, role, email, phone, extension, avatar_url")
.eq("active", true) .eq("active", true)
.in("id", ["chloe", "riley"]); .in("id", ["chloe", "jeff", "riley"]);
const ops = (opsData || []) as OpsPersona[]; const ops = (opsData || []) as OpsPersona[];
const displayBeat = (p: Persona) => const displayBeat = (p: Persona) =>
@@ -80,7 +81,7 @@ export default async function TeamPage() {
// are set up centrally; this is the authoring address surfaced to readers. // are set up centrally; this is the authoring address surfaced to readers.
const editorialEmail = (p: Persona): { user: string; domain: string } | null => { const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
if (!p.slug) return null; if (!p.slug) return null;
return { user: p.slug, domain: "broadcastbeat.com" }; return { user: p.slug.replace(/-/g, "."), domain: "broadcastbeat.com" };
}; };
const splitEmail = (email: string): { user: string; domain: string } | null => { 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="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10"> <div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
<main> <main>
<header className="mb-8 border-b border-[#252525] pb-6"> {/* Hero */}
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2"> <header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
Broadcast Beat <div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#E67E22,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-[#F0B07A] mb-3">
<span className="w-6 h-px bg-[#E67E22]" />
AV Beat &middot; editorial
</div> </div>
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Editorial team</h1> <h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
<p className="mt-3 text-[#9ca3af] text-base"> The team behind every byline
Staff journalists by beat a mix of human reporters and trained AI personas operating under house style. </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> </p>
</div>
</header> </header>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5"> {/* Editorial team — three-col grid on desktop, larger portraits */}
{team.map((p) => ( <section className="mb-14">
<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"> <div className="flex items-baseline justify-between mb-5">
{p.avatar_url && ( <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>
<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-[#E67E22]/50 hover:shadow-[0_0_0_1px_rgba(230,126,34,0.15)] transition-all"
>
{/* Top accent */}
<div className="absolute top-0 left-5 right-5 h-px bg-gradient-to-r from-transparent via-[#E67E22]/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 <img
src={p.avatar_url} src={p.avatar_url}
alt={p.name} alt={p.name}
width={64} width={96}
height={64} height={96}
className="rounded-full border border-[#252525] flex-shrink-0" className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] group-hover:border-[#E67E22]/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"> <div className="flex-1 min-w-0">
<h2 className="font-serif text-lg font-semibold">{p.name}</h2> <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") && ( {(p.beat || p.slug === "ryan-salazar") && (
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5"> <div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#E67E22]/10 border border-[#E67E22]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#F0B07A]">
{displayBeat(p)} {displayBeat(p)}
</div> </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> </div>
</li> </div>
))}
</ul>
{ops.length > 0 && ( {p.bio && (
<> <p className="text-sm text-[#aab1bd] mt-4 leading-relaxed line-clamp-4">
<header className="mt-12 mb-6 border-b border-[#252525] pb-4"> {p.bio}
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Accounts &amp; support</h2>
<p className="mt-2 text-[#9ca3af] text-sm">
For billing, accounts receivable, vendor onboarding, and general support questions these are the people to reach.
</p> </p>
</header> )}
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
{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-[#F0B07A] font-mono"
/>
<a
href={`/authors/${p.slug}`}
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#F0B07A]"
>
View
</a>
</div>
)}
</li>
);
})}
</ul>
</section>
{/* Operations — denser layout */}
{ops.length > 0 && (
<section>
<div className="flex items-baseline justify-between mb-5">
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">
Accounts &amp; support
</h2>
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
</span>
</div>
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
For billing, accounts receivable, vendor onboarding, sales, and
general support questions these are the people to reach.
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{ops.map((p) => ( {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"> <li
<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]"> key={p.id}
className="rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 flex gap-4 hover:border-[#E67E22]/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)} {initials(p.display_name)}
</div> </div>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-serif text-lg font-semibold">{p.display_name}</h3> <h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5"> <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} {p.role}
</div> </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 && (() => { {p.email && (() => {
const e = splitEmail(p.email); const e = splitEmail(p.email);
if (!e) return null; if (!e) return null;
return ( return (
<div> <div className="font-mono">
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" /> <ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#F0B07A]" />
</div> </div>
); );
})()} })()}
{p.phone && ( {p.phone && (
<div> <div>
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#60a5fa]"> <a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#F0B07A] font-mono">
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''} {p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
</a> </a>
</div> </div>
@@ -180,7 +251,7 @@ export default async function TeamPage() {
</li> </li>
))} ))}
</ul> </ul>
</> </section>
)} )}
</main> </main>

View File

@@ -1,11 +1,11 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Advertise With Broadcast Beat — Reach Broadcast Engineering Professionals', title: 'Advertise With AV 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.', 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' }, alternates: { canonical: '/advertise' },
openGraph: { openGraph: {
title: 'Advertise With Broadcast Beat', title: 'Advertise With AV Beat',
description: 'Reach broadcast engineers, production professionals, and media technology decision-makers.', description: 'Reach broadcast engineers, production professionals, and media technology decision-makers.',
url: '/advertise', url: '/advertise',
type: 'website', type: 'website',

View File

@@ -49,7 +49,7 @@ export default function AdvertisePage() {
<span className="section-label mb-3 inline-block">Advertise</span> <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> <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"> <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> </p>
<a <a
href="#contact" href="#contact"
@@ -69,7 +69,7 @@ export default function AdvertisePage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{stats.map((stat) => ( {stats.map((stat) => (
<div key={stat.label} className="bg-[#111] border border-[#222] p-5 text-center"> <div key={stat.label} className="bg-[#111] border border-[#222] p-5 text-center">
<div className="font-heading text-[#3b82f6] text-2xl font-bold mb-1">{stat.value}</div> <div className="font-heading text-[#E67E22] text-2xl font-bold mb-1">{stat.value}</div>
<div className="font-body text-[#777] text-xs uppercase tracking-wide">{stat.label}</div> <div className="font-body text-[#777] text-xs uppercase tracking-wide">{stat.label}</div>
</div> </div>
))} ))}
@@ -86,10 +86,10 @@ export default function AdvertisePage() {
<table className="w-full text-sm font-body"> <table className="w-full text-sm font-body">
<thead> <thead>
<tr className="border-b border-[#222]"> <tr className="border-b border-[#222]">
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Placement</th> <th className="text-left py-3 px-4 text-[#E67E22] font-bold text-xs uppercase tracking-wider">Placement</th>
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Dimensions</th> <th className="text-left py-3 px-4 text-[#E67E22] font-bold text-xs uppercase tracking-wider">Dimensions</th>
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Location</th> <th className="text-left py-3 px-4 text-[#E67E22] font-bold text-xs uppercase tracking-wider">Location</th>
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Rate</th> <th className="text-left py-3 px-4 text-[#E67E22] font-bold text-xs uppercase tracking-wider">Rate</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -98,7 +98,7 @@ export default function AdvertisePage() {
<td className="py-3 px-4 text-[#e0e0e0] font-medium">{zone.name}</td> <td className="py-3 px-4 text-[#e0e0e0] font-medium">{zone.name}</td>
<td className="py-3 px-4 text-[#888] font-mono text-xs">{zone.dimensions}</td> <td className="py-3 px-4 text-[#888] font-mono text-xs">{zone.dimensions}</td>
<td className="py-3 px-4 text-[#777]">{zone.placement}</td> <td className="py-3 px-4 text-[#777]">{zone.placement}</td>
<td className="py-3 px-4 text-[#3b82f6] font-medium">{zone.cpm}</td> <td className="py-3 px-4 text-[#E67E22] font-medium">{zone.cpm}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -123,23 +123,23 @@ export default function AdvertisePage() {
<h3 className="font-heading text-[#e0e0e0] text-lg font-bold mb-4">Sales Contact</h3> <h3 className="font-heading text-[#e0e0e0] text-lg font-bold mb-4">Sales Contact</h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Advertising Sales</p> <p className="font-body text-[#E67E22] text-xs font-bold uppercase tracking-wider mb-1">Advertising Sales</p>
<p className="font-body text-[#e0e0e0] font-medium">Jeff Victor</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"> <a href="mailto:jeff@avbeat.com" className="font-body text-[#E67E22] text-sm hover:underline">
jeff@broadcastbeat.com jeff@avbeat.com
</a> </a>
</div> </div>
<div className="border-t border-[#222] pt-4"> <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-[#E67E22] 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> <p className="font-body text-[#777] text-sm">Relevant Media Properties</p>
</div> </div>
<div className="border-t border-[#222] pt-4"> <div className="border-t border-[#222] pt-4">
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-2">What We Offer</p> <p className="font-body text-[#E67E22] text-xs font-bold uppercase tracking-wider mb-2">What We Offer</p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{["Display advertising (web)", "Newsletter sponsorships", "Sponsored content", "Event coverage partnerships", "Podcast sponsorships"].map((item) => ( {["Display advertising (web)", "Newsletter sponsorships", "Sponsored content", "Event coverage partnerships", "Podcast sponsorships"].map((item) => (
<li key={item} className="flex items-start gap-2 text-[#777] text-sm font-body"> <li key={item} className="flex items-start gap-2 text-[#777] text-sm font-body">
<span className="text-[#3b82f6] mt-0.5"></span> <span className="text-[#E67E22] mt-0.5"></span>
{item} {item}
</li> </li>
))} ))}
@@ -152,16 +152,16 @@ export default function AdvertisePage() {
{/* Form */} {/* Form */}
<div className="lg:col-span-8"> <div className="lg:col-span-8">
{submitted ? ( {submitted ? (
<div className="bg-[#111] border border-[#3b82f6] p-8 text-center"> <div className="bg-[#111] border border-[#E67E22] p-8 text-center">
<div className="w-12 h-12 rounded-full bg-[#3b82f6]/20 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-[#E67E22]/20 flex items-center justify-center mx-auto mb-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12l4 4 10-10" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M5 12l4 4 10-10" stroke="#E67E22" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div> </div>
<h3 className="font-heading text-[#e0e0e0] text-xl font-bold mb-2">Message Sent!</h3> <h3 className="font-heading text-[#e0e0e0] text-xl font-bold mb-2">Message Sent!</h3>
<p className="font-body text-[#777] text-sm"> <p className="font-body text-[#777] text-sm">
Thanks for reaching out. Jeff will be in touch within one business day at{" "} 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-[#E67E22] hover:underline">jeff@avbeat.com</a>.
</p> </p>
</div> </div>
) : ( ) : (
@@ -245,7 +245,7 @@ export default function AdvertisePage() {
{loading ? "Sending..." : "Send Inquiry"} {loading ? "Sending..." : "Send Inquiry"}
</button> </button>
<p className="text-[#555] text-xs font-body"> <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-[#E67E22] hover:underline">jeff@avbeat.com</a>
</p> </p>
</form> </form>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,14 @@ import SidebarAdStack from "@/components/SidebarAdStack";
import StarRating from "@/components/StarRating"; import StarRating from "@/components/StarRating";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import type { Article } from "@/lib/articles/types"; 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 // DO NOT OVERRIDE — ArticleDetailClient interface and session tracking
interface ArticleDetailClientProps { interface ArticleDetailClientProps {
article: Article; article: Article;
relatedArticles: Article[]; relatedArticles: Article[];
relatedForumThreads?: RelatedForumThread[];
} }
function getSessionId(): string { function getSessionId(): string {
@@ -25,7 +28,7 @@ function getSessionId(): string {
return sid; return sid;
} }
export default function ArticleDetailClient({ article, relatedArticles }: ArticleDetailClientProps) { export default function ArticleDetailClient({ article, relatedArticles, relatedForumThreads = [] }: ArticleDetailClientProps) {
const [isSaved, setIsSaved] = useState(false); const [isSaved, setIsSaved] = useState(false);
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle"); const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
const [saveToast, setSaveToast] = useState<string | null>(null); const [saveToast, setSaveToast] = useState<string | null>(null);
@@ -255,8 +258,6 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
<div className="mb-4"> <div className="mb-4">
<StarRating seed={article.slug} publishedAt={article.publishedAt || article.date} size="md" /> <StarRating seed={article.slug} publishedAt={article.publishedAt || article.date} size="md" />
</div> </div>
<p className="font-body text-lg text-[#999] mb-6 leading-relaxed">{article.excerpt}</p>
{/* Article Meta */} {/* Article Meta */}
<div className="flex items-center justify-between py-4 border-t border-b border-[#222]"> <div className="flex items-center justify-between py-4 border-t border-b border-[#222]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -386,6 +387,10 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
Back to {sectionLabel} Back to {sectionLabel}
</Link> </Link>
</div> </div>
{/* Article comments — anonymous read, signed-in members can
post + vote + reply. AI personas participate too (tagged). */}
<ArticleComments articleSlug={article.slug} />
</div> </div>
{/* Sidebar */} {/* Sidebar */}
@@ -424,6 +429,45 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
</div> </div>
</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 */} {/* Sidebar ad stack — 300x600 + 300x250 rotating */}
<SidebarAdStack /> <SidebarAdStack />
</aside> </aside>

View File

@@ -6,6 +6,7 @@ import {
getLegacyArticleBySlug, getLegacyArticleBySlug,
getLegacyRecentSlugs, getLegacyRecentSlugs,
getLegacyRelatedArticles, getLegacyRelatedArticles,
getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source"; } from "@/lib/articles/legacy-source";
export const revalidate = 3600; export const revalidate = 3600;
@@ -33,11 +34,15 @@ function absoluteImage(src: string | undefined | null): string {
async function loadArticle(slug: string): Promise<{ async function loadArticle(slug: string): Promise<{
article: Article | null; article: Article | null;
related: Article[]; related: Article[];
relatedForumThreads: RelatedForumThread[];
}> { }> {
const imported = await getLegacyArticleBySlug(slug); const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [] }; if (!imported) return { article: null, related: [], relatedForumThreads: [] };
const related = await getLegacyRelatedArticles(slug, imported.category, 3); const [related, relatedForumThreads] = await Promise.all([
return { article: imported, related }; getLegacyRelatedArticles(slug, imported.category, 3),
getRelatedForumThreads(imported.title, 6),
]);
return { article: imported, related, relatedForumThreads };
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -77,7 +82,7 @@ export async function generateStaticParams() {
export default async function ArticlePage({ params }: PageProps) { export default async function ArticlePage({ params }: PageProps) {
const { slug } = await params; const { slug } = await params;
const { article, related } = await loadArticle(slug); const { article, related, relatedForumThreads } = await loadArticle(slug);
if (!article) { if (!article) {
// Slug isn't in any of our article tables — send the reader to the news // 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. // index so a click from the homepage/ticker doesn't dead-end on 404.
@@ -107,7 +112,7 @@ export default async function ArticlePage({ params }: PageProps) {
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/> />
<ArticleDetailPage article={article} relatedArticles={related} /> <ArticleDetailPage article={article} relatedArticles={related} relatedForumThreads={relatedForumThreads} />
</> </>
); );
} }

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
export const metadata: Metadata = { 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.', 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' }, alternates: { canonical: '/forum' },
openGraph: { 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.', description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Ask questions, share expertise, and discuss broadcast technology.',
url: '/forum', url: '/forum',
type: 'website', type: 'website',
}, },
twitter: { twitter: {
card: 'summary', card: 'summary',
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 on Broadcast 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 = { const schema = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebPage', '@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.', 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: { isPartOf: {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Broadcast Beat', name: 'AV Beat',
url: 'https://broadcastbeat.com', url: 'https://avbeat.com',
}, },
breadcrumb: { breadcrumb: {
'@type': 'BreadcrumbList', '@type': 'BreadcrumbList',
itemListElement: [ itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://broadcastbeat.com' }, { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://avbeat.com' },
{ '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://broadcastbeat.com/forum' }, { '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://avbeat.com/forum' },
], ],
}, },
}; };

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header'; import Header from '@/components/Header';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
function safeNext(next: string | null): string { function safeNext(next: string | null): string {
@@ -50,17 +51,18 @@ function ForumLoginInner() {
return ( return (
<> <>
<Header /> <Header />
<main className="min-h-screen bg-[#111111]"> <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-12"> <div className="max-w-md mx-auto px-4 py-16">
<div className="text-center mb-6"> <div className="text-center mb-6">
<h1 className="text-2xl font-heading font-bold text-white">Sign in to the Forum</h1> <div className="text-[10px] uppercase tracking-widest text-[#E67E22] font-mono mb-2">Welcome back</div>
<p className="text-[#888] font-body text-sm mt-1"> <h1 className="text-3xl font-heading font-bold text-white">Sign in to AV Beat</h1>
Use your Broadcast Beat account to post, reply, and vote. <p className="text-[#888] font-body text-sm mt-2">
Your account covers the forum, newsletter preferences, and saved articles.
</p> </p>
</div> </div>
<form <form
onSubmit={handleSubmit} 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 && ( {error && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded"> <div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
@@ -77,7 +79,7 @@ function ForumLoginInner() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoComplete="email" autoComplete="email"
required 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]" 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-[#E67E22]"
/> />
</div> </div>
<div> <div>
@@ -90,21 +92,30 @@ function ForumLoginInner() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
required 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]" 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-[#E67E22]"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} 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-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
> >
{loading ? 'Signing in…' : 'Sign In'} {loading ? 'Signing in…' : 'Sign In'}
</button> </button>
<div className="text-right text-xs font-body">
<Link
href={`/forum/forgot-password?email=${encodeURIComponent(email)}`}
className="text-[#888] hover:text-[#E67E22] hover:underline"
>
Forgot password?
</Link>
</div>
<SocialAuthButtons next={next} />
<div className="text-center text-xs font-body text-[#888] pt-2"> <div className="text-center text-xs font-body text-[#888] pt-2">
No account yet?{' '} No account yet?{' '}
<Link <Link
href={`/forum/register?next=${encodeURIComponent(next)}`} href={`/forum/register?next=${encodeURIComponent(next)}`}
className="text-[#3b82f6] hover:underline" className="text-[#E67E22] hover:underline"
> >
Create one Create one
</Link> </Link>

View File

@@ -103,7 +103,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
const getMetricValue = (thread: ForumThread) => { const getMetricValue = (thread: ForumThread) => {
if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' }; if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' };
if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#3b82f6]' }; if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#E67E22]' };
return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' }; return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' };
}; };
@@ -123,7 +123,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
onClick={() => setActiveMetric(m.value)} onClick={() => setActiveMetric(m.value)}
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${ className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
activeMetric === m.value activeMetric === m.value
? 'bg-[#3b82f6] text-white' ? 'bg-[#E67E22] text-white'
: 'text-[#666] hover:text-[#aaa]' : 'text-[#666] hover:text-[#aaa]'
}`} }`}
> >
@@ -171,13 +171,13 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
</span> </span>
{/* Thread info */} {/* Thread info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-white font-body text-sm font-semibold group-hover:text-[#3b82f6] transition-colors truncate leading-snug"> <p className="text-white font-body text-sm font-semibold group-hover:text-[#E67E22] transition-colors truncate leading-snug">
{thread.title} {thread.title}
</p> </p>
<p className="text-[#555] font-body text-xs mt-0.5"> <p className="text-[#555] font-body text-xs mt-0.5">
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link> <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#E67E22] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{thread.forum_categories && ( {thread.forum_categories && (
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></> <> · <span className="text-[#E67E22]">{thread.forum_categories.name}</span></>
)} )}
{' · '}{timeAgo(thread.created_at)} {' · '}{timeAgo(thread.created_at)}
</p> </p>
@@ -301,7 +301,7 @@ export default function ForumIndexPage() {
{currentUserId ? ( {currentUserId ? (
<Link <Link
href="/forum/new" href="/forum/new"
className="inline-flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors" className="inline-flex items-center gap-2 bg-[#E67E22] hover:bg-[#C8651B] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -311,7 +311,7 @@ export default function ForumIndexPage() {
) : ( ) : (
<Link <Link
href="/forum/login?next=/forum" href="/forum/login?next=/forum"
className="inline-flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors" className="inline-flex items-center gap-2 bg-[#E67E22] hover:bg-[#C8651B] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" /> <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
@@ -333,7 +333,7 @@ export default function ForumIndexPage() {
<button <button
onClick={() => setFeedMode('discover')} onClick={() => setFeedMode('discover')}
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${ className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
feedMode === 'discover' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white' feedMode === 'discover' ? 'bg-[#E67E22] text-white' : 'text-[#888] hover:text-white'
}`} }`}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
@@ -344,7 +344,7 @@ export default function ForumIndexPage() {
<button <button
onClick={() => setFeedMode('following')} onClick={() => setFeedMode('following')}
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${ className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
feedMode === 'following' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white' feedMode === 'following' ? 'bg-[#E67E22] text-white' : 'text-[#888] hover:text-white'
}`} }`}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
@@ -363,7 +363,7 @@ export default function ForumIndexPage() {
<p className="text-[#aab4c4] font-body text-sm mb-3">Sign in to see threads from people you follow.</p> <p className="text-[#aab4c4] font-body text-sm mb-3">Sign in to see threads from people you follow.</p>
<Link <Link
href="/login" href="/login"
className="inline-block bg-[#3b82f6] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#2563eb] transition-colors" className="inline-block bg-[#E67E22] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#C8651B] transition-colors"
> >
Sign In Sign In
</Link> </Link>
@@ -384,7 +384,7 @@ export default function ForumIndexPage() {
<Link <Link
href="/forum" href="/forum"
onClick={() => setFeedMode('discover')} onClick={() => setFeedMode('discover')}
className="inline-block bg-[#3b82f6] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#2563eb] transition-colors" className="inline-block bg-[#E67E22] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#C8651B] transition-colors"
> >
Discover Members Discover Members
</Link> </Link>
@@ -398,10 +398,10 @@ export default function ForumIndexPage() {
<Link <Link
key={thread.id} key={thread.id}
href={`/forum/thread/${thread.id}`} href={`/forum/thread/${thread.id}`}
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg px-4 py-3 transition-all group" className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#E67E22] rounded-lg px-4 py-3 transition-all group"
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-body font-semibold text-sm group-hover:text-[#3b82f6] transition-colors truncate"> <h3 className="text-white font-body font-semibold text-sm group-hover:text-[#E67E22] transition-colors truncate">
{thread.title} {thread.title}
</h3> </h3>
<p className="text-[#666] font-body text-xs mt-0.5"> <p className="text-[#666] font-body text-xs mt-0.5">
@@ -409,7 +409,7 @@ export default function ForumIndexPage() {
<Link <Link
href={`/profile/${thread.author_id}`} href={`/profile/${thread.author_id}`}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
className="text-[#3b82f6] hover:underline" className="text-[#E67E22] hover:underline"
> >
{thread.author_name} {thread.author_name}
</Link> </Link>
@@ -450,7 +450,7 @@ export default function ForumIndexPage() {
placeholder="Search threads..." placeholder="Search threads..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#3b82f6] rounded-lg pl-9 pr-4 py-2.5 text-white font-body text-sm placeholder-[#555] focus:outline-none transition-colors" className="w-full bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#E67E22] rounded-lg pl-9 pr-4 py-2.5 text-white font-body text-sm placeholder-[#555] focus:outline-none transition-colors"
/> />
{search && ( {search && (
<button <button
@@ -470,7 +470,7 @@ export default function ForumIndexPage() {
<select <select
value={selectedCategory} value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)} onChange={e => setSelectedCategory(e.target.value)}
className="appearance-none bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#3b82f6] rounded-lg pl-3 pr-8 py-2.5 text-sm font-body text-white focus:outline-none transition-colors cursor-pointer min-w-[160px]" className="appearance-none bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#E67E22] rounded-lg pl-3 pr-8 py-2.5 text-sm font-body text-white focus:outline-none transition-colors cursor-pointer min-w-[160px]"
> >
<option value="">All Categories</option> <option value="">All Categories</option>
{categories.map(cat => ( {categories.map(cat => (
@@ -490,7 +490,7 @@ export default function ForumIndexPage() {
onClick={() => setSort(opt.value)} onClick={() => setSort(opt.value)}
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${ className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
sort === opt.value sort === opt.value
? 'bg-[#3b82f6] text-white' ? 'bg-[#E67E22] text-white'
: 'text-[#888] hover:text-white' : 'text-[#888] hover:text-white'
}`} }`}
> >
@@ -523,16 +523,16 @@ export default function ForumIndexPage() {
<Link <Link
key={thread.id} key={thread.id}
href={`/forum/thread/${thread.id}`} href={`/forum/thread/${thread.id}`}
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg px-4 py-3 transition-all group" className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#E67E22] rounded-lg px-4 py-3 transition-all group"
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-body font-semibold text-sm group-hover:text-[#3b82f6] transition-colors truncate"> <h3 className="text-white font-body font-semibold text-sm group-hover:text-[#E67E22] transition-colors truncate">
{thread.title} {thread.title}
</h3> </h3>
<p className="text-[#666] font-body text-xs mt-0.5"> <p className="text-[#666] font-body text-xs mt-0.5">
by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link> by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#E67E22] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
{thread.forum_categories && ( {thread.forum_categories && (
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></> <> · <span className="text-[#E67E22]">{thread.forum_categories.name}</span></>
)} )}
{' · '}{timeAgo(thread.created_at)} {' · '}{timeAgo(thread.created_at)}
</p> </p>
@@ -584,11 +584,11 @@ export default function ForumIndexPage() {
<Link <Link
key={cat.id} key={cat.id}
href={`/forum/${cat.slug}`} href={`/forum/${cat.slug}`}
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg p-4 transition-all group" className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#E67E22] rounded-lg p-4 transition-all group"
> >
<span className="text-3xl flex-shrink-0 w-10 text-center">{cat.icon}</span> <span className="text-3xl flex-shrink-0 w-10 text-center">{cat.icon}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-white font-heading font-semibold text-lg group-hover:text-[#3b82f6] transition-colors"> <h2 className="text-white font-heading font-semibold text-lg group-hover:text-[#E67E22] transition-colors">
{cat.name} {cat.name}
</h2> </h2>
<p className="text-[#888] font-body text-sm mt-0.5 truncate">{cat.description}</p> <p className="text-[#888] font-body text-sm mt-0.5 truncate">{cat.description}</p>
@@ -610,7 +610,7 @@ export default function ForumIndexPage() {
<div className="text-white font-body font-semibold text-sm">{cat.post_count}</div> <div className="text-white font-body font-semibold text-sm">{cat.post_count}</div>
<div className="text-[#666] font-body text-xs">posts</div> <div className="text-[#666] font-body text-xs">posts</div>
</div> </div>
<svg className="w-4 h-4 text-[#555] group-hover:text-[#3b82f6] flex-shrink-0 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-[#555] group-hover:text-[#E67E22] flex-shrink-0 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
</Link> </Link>
@@ -629,7 +629,7 @@ export default function ForumIndexPage() {
<ul className="text-[#aab4c4] font-body text-sm space-y-1"> <ul className="text-[#aab4c4] font-body text-sm space-y-1">
<li> Be respectful and professional this is a community of peers</li> <li> Be respectful and professional this is a community of peers</li>
<li> Share real-world experience and cite sources when possible</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-[#E67E22]">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> <li> No spam, self-promotion, or vendor pitches without disclosure</li>
</ul> </ul>
</div> </div>

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Header from '@/components/Header'; import Header from '@/components/Header';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
function safeNext(next: string | null): string { function safeNext(next: string | null): string {
@@ -44,6 +45,34 @@ function ForumRegisterInner() {
if (password !== confirm) return setError('Passwords do not match.'); if (password !== confirm) return setError('Passwords do not match.');
setLoading(true); 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({ const { data, error: signUpError } = await supabase.auth.signUp({
email, email,
password, password,
@@ -72,18 +101,56 @@ function ForumRegisterInner() {
return ( return (
<> <>
<Header /> <Header />
<main className="min-h-screen bg-[#111111]"> <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-12"> <div className="max-w-5xl mx-auto px-4 py-12 grid grid-cols-1 lg:grid-cols-[1fr_440px] gap-10 items-start">
<div className="text-center mb-6"> {/* Left rail — what membership gets you */}
<h1 className="text-2xl font-heading font-bold text-white">Create a Forum Account</h1> <aside className="hidden lg:block">
<div className="text-[10px] uppercase tracking-widest text-[#E67E22] font-mono mb-3">Membership</div>
<h1 className="font-heading text-white text-4xl xl:text-5xl font-bold leading-tight mb-4">
Join AV Beat
</h1>
<p className="text-[#cbd5e1] text-base leading-relaxed mb-6 max-w-md">
One free account for the broadcast engineering community covering NAB, IBC, and the people behind every signal chain.
</p>
<ul className="space-y-3 text-sm text-[#d1d5db] max-w-md">
{[
["💬", "Join the broadcast engineering forum", "Threads, replies, and a verified industry community"],
["📧", "Manage your newsletter preferences", "Pick which categories — Commercial TV, Radio, Industry News, SMPTE — you want delivered"],
["🔖", "Save articles to read later", "Bookmark coverage, build your own reading list"],
["🔔", "Get notified of replies and follow-ups", "Email you when someone responds to a thread you're in"],
].map(([icon, title, desc]) => (
<li key={String(title)} className="flex gap-3 items-start">
<span className="text-xl leading-none flex-shrink-0">{icon}</span>
<div>
<div className="font-semibold text-white">{title}</div>
<div className="text-xs text-[#9ca3af] mt-0.5">{desc}</div>
</div>
</li>
))}
</ul>
<div className="mt-6 text-[11px] text-[#666] leading-relaxed max-w-md border-t border-[#252525] pt-4">
<strong className="text-[#888]">PR firms &amp; contributors:</strong> a separate contributor application is required to submit press releases.
{" "}
<Link href="/about/advertise" className="text-[#E67E22] 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"> <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> </p>
</div> </div>
<form <form
onSubmit={handleSubmit} 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-[#E67E22] font-mono mb-1">Create your account</div>
<div className="text-[#888] text-xs">Takes 30 seconds. Free, no card needed.</div>
</div>
{error && ( {error && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded"> <div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{error} {error}
@@ -104,7 +171,7 @@ function ForumRegisterInner() {
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value)}
autoComplete="name" autoComplete="name"
required 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]" 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-[#E67E22]"
/> />
</div> </div>
<div> <div>
@@ -117,7 +184,7 @@ function ForumRegisterInner() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoComplete="email" autoComplete="email"
required 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]" 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-[#E67E22]"
/> />
</div> </div>
<div> <div>
@@ -131,7 +198,7 @@ function ForumRegisterInner() {
autoComplete="new-password" autoComplete="new-password"
required required
minLength={6} minLength={6}
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]" 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-[#E67E22]"
/> />
</div> </div>
<div> <div>
@@ -144,21 +211,29 @@ function ForumRegisterInner() {
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
required 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]" 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-[#E67E22]"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} 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-[#E67E22] hover:bg-[#C8651B] 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> </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-[#E67E22] underline">terms</Link>
{' '}and{' '}
<Link href="/privacy" className="text-[#888] hover:text-[#E67E22] underline">privacy policy</Link>.
We&rsquo;ll never sell your email.
</p>
<SocialAuthButtons next={next} />
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
Already have an account?{' '} Already have an account?{' '}
<Link <Link
href={`/forum/login?next=${encodeURIComponent(next)}`} href={`/forum/login?next=${encodeURIComponent(next)}`}
className="text-[#3b82f6] hover:underline" className="text-[#E67E22] hover:underline font-semibold"
> >
Sign in Sign in
</Link> </Link>

View File

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

View File

@@ -9,6 +9,8 @@ import { pickAds, rotateAll } from '@/lib/ads';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import SidebarAdStack from "@/components/SidebarAdStack"; import SidebarAdStack from "@/components/SidebarAdStack";
import LinkifyPlainText from "@/components/LinkifyPlainText"; import LinkifyPlainText from "@/components/LinkifyPlainText";
import RichBody from "@/components/forum/RichBody";
import ImageAttachButton from "@/components/forum/ImageAttachButton";
import CompanyMentionsHover from "@/components/CompanyMentionsHover"; import CompanyMentionsHover from "@/components/CompanyMentionsHover";
// Inline ad slot inserted between forum replies every N posts. // Inline ad slot inserted between forum replies every N posts.
@@ -65,8 +67,8 @@ function timeAgo(dateStr: string): string {
function Avatar({ name, isAI }: { name: string; isAI: boolean }) { function Avatar({ name, isAI }: { name: string; isAI: boolean }) {
if (isAI) { if (isAI) {
return ( return (
<div className="w-9 h-9 rounded-full bg-[#1e3a5f] border border-[#3b82f6] flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-full bg-[#1e3a5f] border border-[#E67E22] flex items-center justify-center flex-shrink-0">
<span className="text-[#3b82f6] text-xs font-bold">AI</span> <span className="text-[#E67E22] text-xs font-bold">AI</span>
</div> </div>
); );
} }
@@ -131,7 +133,7 @@ function VoteButtons({ targetType, targetId, upvotes, downvotes, voteScore, isAI
} }
}; };
const scoreColor = counts.vote_score > 0 ? 'text-[#3b82f6]' : counts.vote_score < 0 ? 'text-red-400' : 'text-[#666]'; const scoreColor = counts.vote_score > 0 ? 'text-[#E67E22]' : counts.vote_score < 0 ? 'text-red-400' : 'text-[#666]';
return ( return (
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
@@ -142,8 +144,8 @@ function VoteButtons({ targetType, targetId, upvotes, downvotes, voteScore, isAI
title={!isAuthenticated ? 'Sign in to vote' : isAI ? 'Cannot vote on AI responses' : 'Upvote'} title={!isAuthenticated ? 'Sign in to vote' : isAI ? 'Cannot vote on AI responses' : 'Upvote'}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-body font-semibold transition-colors border ${ className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-body font-semibold transition-colors border ${
currentVote === 1 currentVote === 1
? 'bg-[#3b82f6]/20 border-[#3b82f6] text-[#3b82f6]' ? 'bg-[#E67E22]/20 border-[#E67E22] text-[#E67E22]'
: 'bg-transparent border-[#2a2a2a] text-[#666] hover:border-[#3b82f6] hover:text-[#3b82f6]' : 'bg-transparent border-[#2a2a2a] text-[#666] hover:border-[#E67E22] hover:text-[#E67E22]'
} disabled:opacity-40 disabled:cursor-not-allowed`} } disabled:opacity-40 disabled:cursor-not-allowed`}
aria-label="Upvote" aria-label="Upvote"
> >
@@ -176,7 +178,7 @@ function VoteButtons({ targetType, targetId, upvotes, downvotes, voteScore, isAI
</button> </button>
{!isAuthenticated && ( {!isAuthenticated && (
<Link href="/login" className="text-xs font-body text-[#555] hover:text-[#3b82f6] ml-1 transition-colors"> <Link href="/login" className="text-xs font-body text-[#555] hover:text-[#E67E22] ml-1 transition-colors">
Sign in to vote Sign in to vote
</Link> </Link>
)} )}
@@ -320,7 +322,7 @@ export default function ForumThreadPage() {
<div className="bg-red-900/20 border border-red-800 rounded-lg p-4 text-red-400 font-body"> <div className="bg-red-900/20 border border-red-800 rounded-lg p-4 text-red-400 font-body">
{error || 'Thread not found.'} {error || 'Thread not found.'}
</div> </div>
<Link href="/forum" className="mt-4 inline-block text-[#3b82f6] hover:underline font-body text-sm"> <Link href="/forum" className="mt-4 inline-block text-[#E67E22] hover:underline font-body text-sm">
Back to Forum Back to Forum
</Link> </Link>
</div> </div>
@@ -340,11 +342,11 @@ export default function ForumThreadPage() {
<div className="bg-[#1a2535] border-b border-[#2a3a50]"> <div className="bg-[#1a2535] border-b border-[#2a3a50]">
<div className="max-w-container mx-auto px-4 py-4"> <div className="max-w-container mx-auto px-4 py-4">
<nav className="text-sm font-body text-[#666]"> <nav className="text-sm font-body text-[#666]">
<Link href="/forum" className="hover:text-[#3b82f6] transition-colors">Forum</Link> <Link href="/forum" className="hover:text-[#E67E22] transition-colors">Forum</Link>
<span className="mx-2"></span> <span className="mx-2"></span>
{category && ( {category && (
<> <>
<Link href={`/forum/${category.slug}`} className="hover:text-[#3b82f6] transition-colors">{category.name}</Link> <Link href={`/forum/${category.slug}`} className="hover:text-[#E67E22] transition-colors">{category.name}</Link>
<span className="mx-2"></span> <span className="mx-2"></span>
</> </>
)} )}
@@ -364,14 +366,14 @@ export default function ForumThreadPage() {
<Avatar name={thread.author_name} isAI={false} /> <Avatar name={thread.author_name} isAI={false} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#3b82f6] hover:underline">{thread.author_name}</Link> <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#E67E22] hover:underline">{thread.author_name}</Link>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span> <span className="text-[#555] font-body text-xs">{timeAgo(thread.created_at)}</span>
<span className="text-[#555] font-body text-xs">·</span> <span className="text-[#555] font-body text-xs">·</span>
<span className="text-[#555] font-body text-xs">{thread.view_count} views</span> <span className="text-[#555] font-body text-xs">{thread.view_count} views</span>
</div> </div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed"> <p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<LinkifyPlainText body={thread.body} /> <RichBody body={thread.body} />
</p> </p>
<VoteButtons <VoteButtons
targetType="thread" targetType="thread"
@@ -401,9 +403,9 @@ export default function ForumThreadPage() {
<Avatar name={reply.author_name} isAI={reply.is_ai_response} /> <Avatar name={reply.author_name} isAI={reply.is_ai_response} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
<Link href={`/forum/user/${encodeURIComponent(reply.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#3b82f6] hover:underline">{reply.author_name}</Link> <Link href={`/forum/user/${encodeURIComponent(reply.author_name)}`} className="text-white font-body font-semibold text-sm hover:text-[#E67E22] hover:underline">{reply.author_name}</Link>
{reply.is_ai_response && ( {reply.is_ai_response && (
<span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/30 px-2 py-0.5 rounded font-body font-semibold"> <span className="text-xs bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/30 px-2 py-0.5 rounded font-body font-semibold">
AI Assistant AI Assistant
</span> </span>
)} )}
@@ -413,7 +415,7 @@ export default function ForumThreadPage() {
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span> <span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
</div> </div>
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed"> <p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
<LinkifyPlainText body={reply.body} /> <RichBody body={reply.body} />
</p> </p>
<VoteButtons <VoteButtons
targetType="reply" targetType="reply"
@@ -441,12 +443,12 @@ export default function ForumThreadPage() {
{/* AI Assistant Panel */} {/* AI Assistant Panel */}
<div className="bg-[#0d1f35] border border-[#1e3a5f] rounded-lg p-5 mb-6"> <div className="bg-[#0d1f35] border border-[#1e3a5f] rounded-lg p-5 mb-6">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 rounded-full bg-[#1e3a5f] border border-[#3b82f6] flex items-center justify-center"> <div className="w-7 h-7 rounded-full bg-[#1e3a5f] border border-[#E67E22] flex items-center justify-center">
<span className="text-[#3b82f6] text-xs font-bold">AI</span> <span className="text-[#E67E22] text-xs font-bold">AI</span>
</div> </div>
<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> <span className="ml-2 text-xs bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/30 px-2 py-0.5 rounded font-body">AI Not a human</span>
</div> </div>
</div> </div>
<p className="text-[#7a9ab8] font-body text-xs mb-3"> <p className="text-[#7a9ab8] font-body text-xs mb-3">
@@ -459,12 +461,12 @@ export default function ForumThreadPage() {
value={aiQuestion} value={aiQuestion}
onChange={e => setAiQuestion(e.target.value)} onChange={e => setAiQuestion(e.target.value)}
disabled={aiLoading} disabled={aiLoading}
className="flex-1 bg-[#111] border border-[#2a3a50] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#3a5a7a] focus:outline-none focus:border-[#3b82f6] disabled:opacity-50" className="flex-1 bg-[#111] border border-[#2a3a50] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#3a5a7a] focus:outline-none focus:border-[#E67E22] disabled:opacity-50"
/> />
<button <button
type="submit" type="submit"
disabled={aiLoading || !aiQuestion.trim()} disabled={aiLoading || !aiQuestion.trim()}
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors flex-shrink-0" className="bg-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors flex-shrink-0"
> >
{aiLoading ? ( {aiLoading ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -493,8 +495,12 @@ export default function ForumThreadPage() {
onChange={e => setReplyBody(e.target.value)} onChange={e => setReplyBody(e.target.value)}
required required
rows={5} 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" 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-[#E67E22] resize-none"
/> />
<div className="flex items-center gap-3">
<ImageAttachButton onInsert={(md) => setReplyBody((b) => (b || "") + md)} />
<span className="text-[10px] text-[#666]">Auto-optimized, never &gt; 20 MB</span>
</div>
{replyError && ( {replyError && (
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded"> <div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
{replyError} {replyError}
@@ -503,7 +509,7 @@ export default function ForumThreadPage() {
<button <button
type="submit" type="submit"
disabled={submitting || !replyBody.trim()} disabled={submitting || !replyBody.trim()}
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors" className="bg-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
> >
{submitting ? 'Posting...' : 'Post Reply'} {submitting ? 'Posting...' : 'Post Reply'}
</button> </button>
@@ -520,13 +526,13 @@ export default function ForumThreadPage() {
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<Link <Link
href={`/forum/login?next=${encodeURIComponent(`/forum/thread/${threadId}`)}`} href={`/forum/login?next=${encodeURIComponent(`/forum/thread/${threadId}`)}`}
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors" className="bg-[#E67E22] hover:bg-[#C8651B] text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
> >
Sign In Sign In
</Link> </Link>
<Link <Link
href={`/forum/register?next=${encodeURIComponent(`/forum/thread/${threadId}`)}`} href={`/forum/register?next=${encodeURIComponent(`/forum/thread/${threadId}`)}`}
className="border border-[#3b82f6] text-[#3b82f6] hover:bg-[#3b82f6]/10 font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors" className="border border-[#E67E22] text-[#E67E22] hover:bg-[#E67E22]/10 font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
> >
Register Register
</Link> </Link>

View File

@@ -16,13 +16,13 @@ export default function GlobalError({
<body style={{ background: "#0d0d0d", color: "#e0e0e0", fontFamily: "system-ui", padding: 24, minHeight: "100vh" }}> <body style={{ background: "#0d0d0d", color: "#e0e0e0", fontFamily: "system-ui", padding: 24, minHeight: "100vh" }}>
<div style={{ maxWidth: 480, margin: "10vh auto", textAlign: "center" }}> <div style={{ maxWidth: 480, margin: "10vh auto", textAlign: "center" }}>
<h2 style={{ fontSize: 22, marginBottom: 12 }}>Something went wrong</h2> <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 && ( {error?.digest && (
<p style={{ fontSize: 12, opacity: 0.5, marginBottom: 16, fontFamily: "monospace" }}>ref: {error.digest}</p> <p style={{ fontSize: 12, opacity: 0.5, marginBottom: 16, fontFamily: "monospace" }}>ref: {error.digest}</p>
)} )}
<button <button
onClick={() => reset()} onClick={() => reset()}
style={{ padding: "8px 16px", background: "#3b82f6", color: "white", border: 0, cursor: "pointer", borderRadius: 2 }} style={{ padding: "8px 16px", background: "#E67E22", color: "white", border: 0, cursor: "pointer", borderRadius: 2 }}
> >
Try again Try again
</button> </button>

View File

@@ -314,7 +314,7 @@ export default function ArticleFeed() {
value={sortOrder} value={sortOrder}
onChange={(e) => { setSortOrder(e.target.value as SortOrder); setPage(1); setInfiniteCount(ARTICLES_PER_PAGE); }} onChange={(e) => { setSortOrder(e.target.value as SortOrder); setPage(1); setInfiniteCount(ARTICLES_PER_PAGE); }}
aria-label="Sort articles" aria-label="Sort articles"
className="bg-[#111] border border-[#2a2a2a] text-[#aaa] font-body text-[11px] font-semibold uppercase tracking-wide rounded-sm pl-2.5 pr-7 py-1 appearance-none focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]/30 transition-colors [color-scheme:dark] cursor-pointer hover:border-[#3a3a3a]" className="bg-[#111] border border-[#2a2a2a] text-[#aaa] font-body text-[11px] font-semibold uppercase tracking-wide rounded-sm pl-2.5 pr-7 py-1 appearance-none focus:outline-none focus:border-[#E67E22] focus:ring-1 focus:ring-[#E67E22]/30 transition-colors [color-scheme:dark] cursor-pointer hover:border-[#3a3a3a]"
> >
<option value="recent">Recent</option> <option value="recent">Recent</option>
<option value="trending">Trending</option> <option value="trending">Trending</option>
@@ -333,8 +333,8 @@ export default function ArticleFeed() {
onClick={() => handleFeedModeChange("pagination")} onClick={() => handleFeedModeChange("pagination")}
aria-pressed={feedMode === "pagination"} aria-pressed={feedMode === "pagination"}
title="Pagination mode" title="Pagination mode"
className={`flex items-center gap-1 px-2.5 py-1 rounded-sm font-body text-[10px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`flex items-center gap-1 px-2.5 py-1 rounded-sm font-body text-[10px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
feedMode === "pagination" ?"bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/40" :"text-[#666] hover:text-[#aaa] border border-transparent" feedMode === "pagination" ?"bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/40" :"text-[#666] hover:text-[#aaa] border border-transparent"
}`}> }`}>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<rect x="1" y="1" width="10" height="2.5" rx="0.5" fill="currentColor" opacity="0.9"/> <rect x="1" y="1" width="10" height="2.5" rx="0.5" fill="currentColor" opacity="0.9"/>
@@ -347,8 +347,8 @@ export default function ArticleFeed() {
onClick={() => handleFeedModeChange("infinite")} onClick={() => handleFeedModeChange("infinite")}
aria-pressed={feedMode === "infinite"} aria-pressed={feedMode === "infinite"}
title="Infinite scroll mode" title="Infinite scroll mode"
className={`flex items-center gap-1 px-2.5 py-1 rounded-sm font-body text-[10px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`flex items-center gap-1 px-2.5 py-1 rounded-sm font-body text-[10px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
feedMode === "infinite" ?"bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/40" :"text-[#666] hover:text-[#aaa] border border-transparent" feedMode === "infinite" ?"bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/40" :"text-[#666] hover:text-[#aaa] border border-transparent"
}`}> }`}>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<rect x="1" y="1" width="10" height="2" rx="0.5" fill="currentColor" opacity="0.9"/> <rect x="1" y="1" width="10" height="2" rx="0.5" fill="currentColor" opacity="0.9"/>
@@ -372,7 +372,7 @@ export default function ArticleFeed() {
value={dateFrom} value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }} onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
max={dateTo || undefined} max={dateTo || undefined}
className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors [color-scheme:dark]" className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#E67E22] transition-colors [color-scheme:dark]"
title="From date" title="From date"
aria-label="Filter articles from date" aria-label="Filter articles from date"
/> />
@@ -382,7 +382,7 @@ export default function ArticleFeed() {
value={dateTo} value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1); }} onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
min={dateFrom || undefined} min={dateFrom || undefined}
className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors [color-scheme:dark]" className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#E67E22] transition-colors [color-scheme:dark]"
title="To date" title="To date"
aria-label="Filter articles to date" aria-label="Filter articles to date"
/> />
@@ -401,9 +401,9 @@ export default function ArticleFeed() {
onClick={() => setShowAdvanced((v) => !v)} onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced} aria-expanded={showAdvanced}
aria-controls={`${feedId}-advanced`} aria-controls={`${feedId}-advanced`}
className={`flex items-center gap-1.5 px-3 py-2 rounded-sm border font-body text-[11px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] whitespace-nowrap ${ className={`flex items-center gap-1.5 px-3 py-2 rounded-sm border font-body text-[11px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] whitespace-nowrap ${
showAdvanced || hasAdvancedActive showAdvanced || hasAdvancedActive
? "bg-[#3b82f6]/15 text-[#3b82f6] border-[#3b82f6]/40" ? "bg-[#E67E22]/15 text-[#E67E22] border-[#E67E22]/40"
: "bg-[#111] text-[#666] border-[#2a2a2a] hover:text-[#aaa] hover:border-[#3a3a3a]" : "bg-[#111] text-[#666] border-[#2a2a2a] hover:text-[#aaa] hover:border-[#3a3a3a]"
}`}> }`}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
@@ -411,7 +411,7 @@ export default function ArticleFeed() {
</svg> </svg>
More More
{hasAdvancedActive && ( {hasAdvancedActive && (
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] inline-block ml-0.5" aria-label="Active filters" /> <span className="w-1.5 h-1.5 rounded-full bg-[#E67E22] inline-block ml-0.5" aria-label="Active filters" />
)} )}
</button> </button>
</div> </div>
@@ -428,9 +428,9 @@ export default function ArticleFeed() {
type="button" type="button"
onClick={() => { setActiveCategory(cat); setPage(1); }} onClick={() => { setActiveCategory(cat); setPage(1); }}
aria-pressed={isActive} aria-pressed={isActive}
className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
isActive isActive
? "bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/50" ? "bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/50"
: "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc] hover:border-[#3a3a3a]" : "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc] hover:border-[#3a3a3a]"
}`}> }`}>
{cat} {cat}
@@ -458,7 +458,7 @@ export default function ArticleFeed() {
id={`${feedId}-author`} id={`${feedId}-author`}
value={authorFilter} value={authorFilter}
onChange={(e) => { setAuthorFilter(e.target.value); setPage(1); }} onChange={(e) => { setAuthorFilter(e.target.value); setPage(1); }}
className="w-full bg-[#111] border border-[#2a2a2a] text-[#ccc] font-body text-sm rounded-sm px-3 py-1.5 pr-8 appearance-none focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]/30 transition-colors [color-scheme:dark]" className="w-full bg-[#111] border border-[#2a2a2a] text-[#ccc] font-body text-sm rounded-sm px-3 py-1.5 pr-8 appearance-none focus:outline-none focus:border-[#E67E22] focus:ring-1 focus:ring-[#E67E22]/30 transition-colors [color-scheme:dark]"
aria-label="Filter by author"> aria-label="Filter by author">
<option value="All">All Authors</option> <option value="All">All Authors</option>
{uniqueAuthors.map((author) => ( {uniqueAuthors.map((author) => (
@@ -483,10 +483,10 @@ export default function ArticleFeed() {
key={sf} key={sf}
onClick={() => { setSourceFilter(sf); setPage(1); }} onClick={() => { setSourceFilter(sf); setPage(1); }}
aria-pressed={isActive} aria-pressed={isActive}
className={`px-3 py-1 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`px-3 py-1 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
isActive isActive
? sf === "Imported" ?"bg-amber-500/20 text-amber-400 border border-amber-500/40" ? sf === "Imported" ?"bg-amber-500/20 text-amber-400 border border-amber-500/40"
: sf === "Live" ?"bg-emerald-500/20 text-emerald-400 border border-emerald-500/40" :"bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/40" :"text-[#666] hover:text-[#aaa] border border-transparent" : sf === "Live" ?"bg-emerald-500/20 text-emerald-400 border border-emerald-500/40" :"bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/40" :"text-[#666] hover:text-[#aaa] border border-transparent"
}`}> }`}>
{sf} {sf}
{sf === "Imported" && importedCount > 0 && ( {sf === "Imported" && importedCount > 0 && (
@@ -507,7 +507,7 @@ export default function ArticleFeed() {
<button <button
type="button" type="button"
onClick={() => { setAuthorFilter("All"); setDateFrom(""); setDateTo(""); setPage(1); }} onClick={() => { setAuthorFilter("All"); setDateFrom(""); setDateTo(""); setPage(1); }}
className="font-body text-[11px] text-[#3b82f6] hover:underline focus:outline-none focus-visible:underline"> className="font-body text-[11px] text-[#E67E22] hover:underline focus:outline-none focus-visible:underline">
Clear advanced filters Clear advanced filters
</button> </button>
</div> </div>
@@ -537,7 +537,7 @@ export default function ArticleFeed() {
{sortedFiltered.length === 0 && ( {sortedFiltered.length === 0 && (
<button <button
onClick={clearAllFilters} onClick={clearAllFilters}
className="ml-1 text-[#3b82f6] hover:underline focus:outline-none focus-visible:underline"> className="ml-1 text-[#E67E22] hover:underline focus:outline-none focus-visible:underline">
Clear filters Clear filters
</button> </button>
)} )}
@@ -557,7 +557,7 @@ export default function ArticleFeed() {
</p> </p>
<button <button
onClick={clearAllFilters} onClick={clearAllFilters}
className="mt-4 font-body text-sm text-[#3b82f6] hover:underline focus:outline-none focus-visible:underline"> className="mt-4 font-body text-sm text-[#E67E22] hover:underline focus:outline-none focus-visible:underline">
Clear filters Clear filters
</button> </button>
</div> </div>
@@ -585,7 +585,7 @@ export default function ArticleFeed() {
const promotedClass = promoted === 'premium' const promotedClass = promoted === 'premium'
? 'border-l-4 border-amber-400 bg-gradient-to-r from-amber-500/10 to-transparent pl-3 -ml-2 mr-0' ? 'border-l-4 border-amber-400 bg-gradient-to-r from-amber-500/10 to-transparent pl-3 -ml-2 mr-0'
: promoted === 'client' : promoted === 'client'
? 'border-l-4 border-[#3b82f6] bg-gradient-to-r from-[#3b82f6]/8 to-transparent pl-3 -ml-2 mr-0' ? 'border-l-4 border-[#E67E22] bg-gradient-to-r from-[#E67E22]/8 to-transparent pl-3 -ml-2 mr-0'
: ''; : '';
return ( return (
<Fragment key={`feed-${i}`}> <Fragment key={`feed-${i}`}>
@@ -593,7 +593,7 @@ export default function ArticleFeed() {
<Link <Link
href={articleHref} href={articleHref}
{...linkProps} {...linkProps}
className="flex-shrink-0 img-zoom overflow-hidden w-[100px] h-[70px] sm:w-[140px] sm:h-[95px] md:w-[190px] md:h-[128px] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]" className="flex-shrink-0 img-zoom overflow-hidden w-[100px] h-[70px] sm:w-[140px] sm:h-[95px] md:w-[190px] md:h-[128px] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]"
tabIndex={-1} tabIndex={-1}
aria-hidden="true" aria-hidden="true"
> >
@@ -614,21 +614,21 @@ export default function ArticleFeed() {
</span> </span>
)} )}
{promoted === 'client' && ( {promoted === 'client' && (
<span className="font-body text-[9px] font-bold text-[#7aa7d4] bg-[#3b82f6]/15 border border-[#3b82f6]/40 px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Advertiser coverage: ${article.promoted_advertiser}` : 'Advertiser coverage'}> <span className="font-body text-[9px] font-bold text-[#7aa7d4] bg-[#E67E22]/15 border border-[#E67E22]/40 px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Advertiser coverage: ${article.promoted_advertiser}` : 'Advertiser coverage'}>
Advertiser Advertiser
</span> </span>
)} )}
{article?.category && article.category.toLowerCase() !== "legacy" && ( {article?.category && article.category.toLowerCase() !== "legacy" && (
<span className="font-body text-[10px] font-bold text-[#3b82f6] uppercase tracking-wide"> <span className="font-body text-[10px] font-bold text-[#E67E22] uppercase tracking-wide">
{article.category} {article.category}
</span> </span>
)} )}
</div> </div>
<h3 className="font-heading text-[#e0e0e0] font-bold text-[0.875rem] md:text-[1rem] leading-snug mb-1 md:mb-1.5 group-hover:text-[#3b82f6] transition-colors duration-200 line-clamp-2"> <h3 className="font-heading text-[#e0e0e0] font-bold text-[0.875rem] md:text-[1rem] leading-snug mb-1 md:mb-1.5 group-hover:text-[#E67E22] transition-colors duration-200 line-clamp-2">
<Link <Link
href={articleHref} href={articleHref}
{...linkProps} {...linkProps}
className="focus:outline-none focus-visible:text-[#3b82f6] focus-visible:underline"> className="focus:outline-none focus-visible:text-[#E67E22] focus-visible:underline">
{article?.title} {article?.title}
</Link> </Link>
</h3> </h3>
@@ -664,7 +664,7 @@ export default function ArticleFeed() {
{/* Inline social share row */} {/* 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 || ''; const shareTitle = article?.title || '';
return ( return (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-[#1a1a1a]"> <div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-[#1a1a1a]">
@@ -675,7 +675,7 @@ export default function ArticleFeed() {
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#0a66c2] hover:bg-[#1a1a1a] transition-colors"> className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#0a66c2] hover:bg-[#1a1a1a] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 0h-14C2.24 0 0 2.24 0 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5V5c0-2.76-2.24-5-5-5zM8 19H5V8h3v11zM6.5 6.73a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zM20 19h-3v-5.6c0-3.37-4-3.12-4 0V19h-3V8h3v1.77c1.4-2.59 7-2.78 7 2.48V19z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 0h-14C2.24 0 0 2.24 0 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5V5c0-2.76-2.24-5-5-5zM8 19H5V8h3v11zM6.5 6.73a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zM20 19h-3v-5.6c0-3.37-4-3.12-4 0V19h-3V8h3v1.77c1.4-2.59 7-2.78 7 2.48V19z"/></svg>
</a> </a>
<a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareTitle)}&via=BroadcastBeat`} <a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareTitle)}&via=AVBeat`}
target="_blank" rel="noopener noreferrer" aria-label="Share on X" title="X (Twitter)" target="_blank" rel="noopener noreferrer" aria-label="Share on X" title="X (Twitter)"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-white hover:bg-[#1a1a1a] transition-colors"> className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-white hover:bg-[#1a1a1a] transition-colors">
@@ -702,7 +702,7 @@ export default function ArticleFeed() {
<a href={`mailto:?subject=${encodeURIComponent(shareTitle)}&body=${encodeURIComponent(shareUrl)}`} <a href={`mailto:?subject=${encodeURIComponent(shareTitle)}&body=${encodeURIComponent(shareUrl)}`}
aria-label="Share via email" title="Email" aria-label="Share via email" title="Email"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#3b82f6] hover:bg-[#1a1a1a] transition-colors"> className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#E67E22] hover:bg-[#1a1a1a] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /> <rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg> </svg>
@@ -735,9 +735,9 @@ export default function ArticleFeed() {
{isLoadingMore && ( {isLoadingMore && (
<div className="flex items-center justify-center gap-2 py-6" aria-live="polite" aria-label="Loading more articles"> <div className="flex items-center justify-center gap-2 py-6" aria-live="polite" aria-label="Loading more articles">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] animate-bounce" style={{ animationDelay: "0ms" }} /> <span className="w-1.5 h-1.5 rounded-full bg-[#E67E22] animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] animate-bounce" style={{ animationDelay: "150ms" }} /> <span className="w-1.5 h-1.5 rounded-full bg-[#E67E22] animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] animate-bounce" style={{ animationDelay: "300ms" }} /> <span className="w-1.5 h-1.5 rounded-full bg-[#E67E22] animate-bounce" style={{ animationDelay: "300ms" }} />
</div> </div>
<span className="font-body text-xs text-[#555]">Loading more articles</span> <span className="font-body text-xs text-[#555]">Loading more articles</span>
</div> </div>
@@ -762,7 +762,7 @@ export default function ArticleFeed() {
</p> </p>
<div className="flex items-center gap-1" role="navigation" aria-label="Pagination"> <div className="flex items-center gap-1" role="navigation" aria-label="Pagination">
<button <button
className="page-btn inline-flex items-center gap-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]" className="page-btn inline-flex items-center gap-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]"
disabled={page === 1} disabled={page === 1}
onClick={() => { setPage(Math.max(1, page - 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(Math.max(1, page - 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
aria-label="Previous page"> aria-label="Previous page">
@@ -786,7 +786,7 @@ export default function ArticleFeed() {
) : ( ) : (
<button <button
key={p} key={p}
className={`page-btn focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${page === p ? "active" : ""}`} className={`page-btn focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${page === p ? "active" : ""}`}
onClick={() => { setPage(p as number); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(p as number); window.scrollTo({ top: 0, behavior: "smooth" }); }}
aria-label={`Page ${p}`} aria-label={`Page ${p}`}
aria-current={page === p ? "page" : undefined}> aria-current={page === p ? "page" : undefined}>
@@ -796,7 +796,7 @@ export default function ArticleFeed() {
); );
})()} })()}
<button <button
className="page-btn inline-flex items-center gap-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]" className="page-btn inline-flex items-center gap-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]"
disabled={page === totalPages} disabled={page === totalPages}
onClick={() => { setPage(Math.min(totalPages, page + 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(Math.min(totalPages, page + 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
aria-label="Next page"> aria-label="Next page">

View File

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

View File

@@ -1,7 +1,8 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
export const runtime = "nodejs"; 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 broadcastbeat.com
@@ -13,24 +14,59 @@ export const dynamic = "force-static";
* /img/campaign-creatives/<uuid>/<file>.jpg * /img/campaign-creatives/<uuid>/<file>.jpg
* /img/bb-forum-avatars/<file>.png * /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") const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com")
.replace(/\/+$/, "") + "/storage/v1/object/public"; .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[] }> }) { export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params; 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("/")}`; const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`;
let upstream: Response; let upstream: Response;
try { try {
upstream = await fetch(target, { cache: "force-cache" }); upstream = await fetchWithRetry(target, 3);
} catch (e: any) { } 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) { 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, { return new Response(upstream.body, {

View File

@@ -34,8 +34,8 @@ export const viewport: Viewport = {
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
title: 'Broadcast Beat — Broadcast Engineering News & Insights', title: 'AV Beat — Where AV Meets IT',
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.', description: 'AV Beat covers the convergence of pro AV and IT — broadcast, live production, streaming, and post-production technology. Breaking news, deep-dive features, and industry spotlights from NAB to InfoComm and beyond.',
icons: { icons: {
icon: [ icon: [
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' }, { url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
@@ -58,25 +58,25 @@ export const metadata: Metadata = {
type: 'website', type: 'website',
locale: 'en_US', locale: 'en_US',
url: '/', url: '/',
siteName: 'Broadcast Beat', siteName: 'AV Beat',
title: 'Broadcast Beat — Broadcast News', title: 'AV Beat — Where AV Meets IT',
description: 'Breaking news and insights for broadcast engineering professionals.', description: 'Breaking news and insights at the intersection of pro AV and IT.',
images: [ images: [
{ {
url: '/assets/images/og-image.png', url: '/assets/images/og-image.png',
width: 1200, width: 1200,
height: 630, height: 630,
alt: 'Broadcast Beat — Broadcast Engineering News & Insights', alt: 'AV Beat — Where AV Meets IT',
type: 'image/png' type: 'image/png'
}] }]
}, },
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
site: '@Broadcast Beat', site: '@AVBeat',
creator: '@Broadcast Beat', creator: '@AVBeat',
title: 'Broadcast Beat — Broadcast News', title: 'AV Beat — Where AV Meets IT',
description: 'Breaking news and insights for broadcast engineering professionals.', description: 'Breaking news and insights at the intersection of pro AV and IT.',
images: ['/assets/images/og-image.png'] images: ['/assets/images/og-image.png']
}, },
robots: { robots: {
@@ -122,10 +122,10 @@ export default function RootLayout({
__html: JSON.stringify({ __html: JSON.stringify({
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Organization', '@type': 'Organization',
name: 'Broadcast Beat', name: 'AV Beat',
url: 'https://broadcastbeat.com', url: 'https://avbeat.com',
logo: "https://broadcastbeat.com/assets/images/logo.png", 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.', description: 'AV Beat covers the convergence of pro AV and IT — broadcast, live production, streaming, and post-production technology.',
sameAs: [ sameAs: [
'https://linkedin.com', 'https://linkedin.com',
'https://twitter.com', 'https://twitter.com',
@@ -135,7 +135,7 @@ export default function RootLayout({
contactPoint: { contactPoint: {
'@type': 'ContactPoint', '@type': 'ContactPoint',
contactType: 'Editorial', contactType: 'Editorial',
url: 'https://broadcastbeat.com' url: 'https://avbeat.com'
} }
}) })
}} /> }} />
@@ -144,7 +144,7 @@ export default function RootLayout({
{/* Skip to main content — WCAG 2.4.1 */} {/* Skip to main content — WCAG 2.4.1 */}
<a <a
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[10000] focus:px-4 focus:py-2 focus:bg-[#3b82f6] focus:text-white focus:rounded focus:font-bold focus:text-sm focus:outline-none" className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[10000] focus:px-4 focus:py-2 focus:bg-[#E67E22] focus:text-white focus:rounded focus:font-bold focus:text-sm focus:outline-none"
> >
Skip to main content Skip to main content
</a> </a>

View File

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

View File

@@ -64,7 +64,7 @@ export default function GigsPage() {
<div className="text-center py-20 text-gray-500"> <div className="text-center py-20 text-gray-500">
<div className="text-4xl mb-4">🎬</div> <div className="text-4xl mb-4">🎬</div>
<p className="text-lg font-semibold mb-2">No gigs posted yet</p> <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>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -64,7 +64,7 @@ export default function JobsPage() {
<div className="text-center py-20 text-gray-500"> <div className="text-center py-20 text-gray-500">
<div className="text-4xl mb-4">📋</div> <div className="text-4xl mb-4">📋</div>
<p className="text-lg font-semibold mb-2">No jobs posted yet</p> <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>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">

View File

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

View File

@@ -9,16 +9,16 @@ import CompanyMentionsHover from "@/components/CompanyMentionsHover";
export const revalidate = 600; export const revalidate = 600;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "2026 NAB Show — Live Coverage Hub | Broadcast Beat", title: "2026 NAB Show — Live Coverage Hub | AV Beat",
description: 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" }, alternates: { canonical: "/nab-2026" },
openGraph: { openGraph: {
title: "2026 NAB Show — Live Coverage Hub", title: "2026 NAB Show — Live Coverage Hub",
description: description:
"Live coverage, exhibitor list, and breaking product news from 2026 NAB Show.", "Live coverage, exhibitor list, and breaking product news from 2026 NAB Show.",
type: "website", type: "website",
url: "https://broadcastbeat.com/nab-2026", url: "https://avbeat.com/nab-2026",
}, },
}; };
@@ -105,7 +105,7 @@ export default async function NabHub() {
</h1> </h1>
<p className="text-[#aaa] text-base max-w-3xl"> <p className="text-[#aaa] text-base max-w-3xl">
{exhibitors.length.toLocaleString()} exhibitors confirmed. {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. {" "}Browse the directory, read the latest product news, and find every booth.
</p> </p>
</div> </div>
@@ -153,7 +153,7 @@ export default async function NabHub() {
<h2 className="text-xl font-display font-bold text-[#e0e0e0]"> <h2 className="text-xl font-display font-bold text-[#e0e0e0]">
Latest from the show floor Latest from the show floor
</h2> </h2>
<Link href="/news?search=NAB+2026" className="text-xs text-[#3b82f6] hover:underline"> <Link href="/news?search=NAB+2026" className="text-xs text-[#E67E22] hover:underline">
See all NAB coverage See all NAB coverage
</Link> </Link>
</header> </header>
@@ -162,7 +162,7 @@ export default async function NabHub() {
<Link <Link
key={a.wp_slug} key={a.wp_slug}
href={`/news/${a.wp_slug}`} href={`/news/${a.wp_slug}`}
className="group bg-[#0d0d0d] border border-[#252525] rounded-lg overflow-hidden hover:border-[#3b82f6] hover:shadow-sm transition-all" className="group bg-[#0d0d0d] border border-[#252525] rounded-lg overflow-hidden hover:border-[#E67E22] hover:shadow-sm transition-all"
> >
{a.featured_image && ( {a.featured_image && (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
@@ -174,7 +174,7 @@ export default async function NabHub() {
/> />
)} )}
<div className="p-3"> <div className="p-3">
<h3 className="font-semibold text-[#e0e0e0] text-sm leading-snug group-hover:text-[#3b82f6] line-clamp-3 mb-1"> <h3 className="font-semibold text-[#e0e0e0] text-sm leading-snug group-hover:text-[#E67E22] line-clamp-3 mb-1">
{a.title} {a.title}
</h3> </h3>
{a.wp_published_at && ( {a.wp_published_at && (
@@ -195,7 +195,7 @@ export default async function NabHub() {
<h2 className="text-xl font-display font-bold text-[#e0e0e0]"> <h2 className="text-xl font-display font-bold text-[#e0e0e0]">
All 2026 NAB Show exhibitors All 2026 NAB Show exhibitors
</h2> </h2>
<Link href="/manufacturers" className="text-xs text-[#3b82f6] hover:underline"> <Link href="/manufacturers" className="text-xs text-[#E67E22] hover:underline">
Full directory Full directory
</Link> </Link>
</header> </header>
@@ -205,7 +205,7 @@ export default async function NabHub() {
key={e.slug} key={e.slug}
href={`/manufacturers/${e.slug}`} href={`/manufacturers/${e.slug}`}
data-company-slug={e.slug} data-company-slug={e.slug}
className="bg-[#0d0d0d] border border-[#252525] rounded-md p-2.5 hover:border-[#3b82f6] transition-all flex items-center gap-2.5" className="bg-[#0d0d0d] border border-[#252525] rounded-md p-2.5 hover:border-[#E67E22] transition-all flex items-center gap-2.5"
> >
{e.logo_url ? ( {e.logo_url ? (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
@@ -222,7 +222,7 @@ export default async function NabHub() {
{otherRows.length > 200 && ( {otherRows.length > 200 && (
<p className="text-center text-xs text-[#888] mt-4"> <p className="text-center text-xs text-[#888] mt-4">
Showing 200 of {otherRows.length.toLocaleString()} exhibitors.{" "} Showing 200 of {otherRows.length.toLocaleString()} exhibitors.{" "}
<Link href="/manufacturers" className="text-[#3b82f6] hover:underline">Browse the full directory </Link> <Link href="/manufacturers" className="text-[#E67E22] hover:underline">Browse the full directory </Link>
</p> </p>
)} )}
</section> </section>

View File

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

View File

@@ -164,7 +164,7 @@ export default function NewsPage({
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e] py-4"> <div className="bg-[#0d0d0d] border-b border-[#1e1e1e] py-4">
<div className="max-w-container mx-auto px-4 space-y-3"> <div className="max-w-container mx-auto px-4 space-y-3">
{/* Row 1: Search + Sort */} {/* Row 1: Search + Sort */}
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
{/* Search */} {/* Search */}
<div className="relative flex-1"> <div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -175,7 +175,7 @@ export default function NewsPage({
placeholder="Search articles, topics, authors…" placeholder="Search articles, topics, authors…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] text-[#e0e0e0] placeholder-[#555] text-sm font-body pl-9 pr-4 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors" className="w-full bg-[#1a1a1a] border border-[#2a2a2a] text-[#e0e0e0] placeholder-[#555] text-sm font-body pl-9 pr-4 py-2.5 rounded-sm focus:outline-none focus:border-[#E67E22] transition-colors"
/> />
{search && ( {search && (
<button <button
@@ -196,7 +196,7 @@ export default function NewsPage({
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors [color-scheme:dark]" className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#E67E22] transition-colors [color-scheme:dark]"
title="From date" title="From date"
/> />
<span className="text-[#444] text-xs"></span> <span className="text-[#444] text-xs"></span>
@@ -204,7 +204,7 @@ export default function NewsPage({
type="date" type="date"
value={dateTo} value={dateTo}
onChange={(e) => setDateTo(e.target.value)} onChange={(e) => setDateTo(e.target.value)}
className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors [color-scheme:dark]" className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#E67E22] transition-colors [color-scheme:dark]"
title="To date" title="To date"
/> />
</div> </div>
@@ -214,7 +214,7 @@ export default function NewsPage({
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value)} onChange={(e) => setSortBy(e.target.value)}
className="appearance-none bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body pl-3 pr-8 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors cursor-pointer" className="appearance-none bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body pl-3 pr-8 py-2.5 rounded-sm focus:outline-none focus:border-[#E67E22] transition-colors cursor-pointer"
> >
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
@@ -236,8 +236,8 @@ export default function NewsPage({
onClick={() => setActiveCategory(cat)} onClick={() => setActiveCategory(cat)}
className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${
activeCategory === cat activeCategory === cat
? "bg-[#3b82f6] border-[#3b82f6] text-white" ? "bg-[#E67E22] border-[#E67E22] text-white"
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]" : "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]"
}`} }`}
> >
{cat} {cat}
@@ -275,7 +275,7 @@ export default function NewsPage({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<p className="text-[#555] font-body text-sm">No articles match your filters.</p> <p className="text-[#555] font-body text-sm">No articles match your filters.</p>
<button onClick={clearFilters} className="mt-3 text-[#3b82f6] text-xs font-body hover:underline">Clear filters</button> <button onClick={clearFilters} className="mt-3 text-[#E67E22] text-xs font-body hover:underline">Clear filters</button>
</div> </div>
) : ( ) : (
<div className="space-y-0"> <div className="space-y-0">
@@ -289,7 +289,7 @@ export default function NewsPage({
<div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm"> <div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm">
<AppImage <AppImage
src={article.image || "/assets/images/article-placeholder.jpg"} src={article.image || "/assets/images/article-placeholder.jpg"}
alt={article.alt || article.title || "Broadcast Beat article"} alt={article.alt || article.title || "AV Beat article"}
fill fill
className="object-cover group-hover:scale-105 transition-transform duration-300" className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="160px" sizes="160px"
@@ -297,14 +297,14 @@ export default function NewsPage({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<span className="text-[#3b82f6] font-body text-[10px] font-bold uppercase tracking-wider">{article.category || "NEWS"}</span> <span className="text-[#E67E22] font-body text-[10px] font-bold uppercase tracking-wider">{article.category || "NEWS"}</span>
</div> </div>
<h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-2"> <h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#E67E22] transition-colors line-clamp-2">
{article.title || "Untitled"} {article.title || "Untitled"}
</h2> </h2>
<p className="text-[#777] font-body text-sm leading-relaxed line-clamp-2">{article.excerpt || ""}</p> <p className="text-[#777] font-body text-sm leading-relaxed line-clamp-2">{article.excerpt || ""}</p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className="text-[#555] font-body text-xs">By {article.author || "Broadcast Beat"}</span> <span className="text-[#555] font-body text-xs">By {article.author || "AV Beat"}</span>
<span className="text-[#444] text-[10px]">·</span> <span className="text-[#444] text-[10px]">·</span>
<span className="text-[#555] font-body text-xs">{article.readTime || ""}</span> <span className="text-[#555] font-body text-xs">{article.readTime || ""}</span>
</div> </div>
@@ -326,7 +326,7 @@ export default function NewsPage({
{!hideAiSuggestions && <AISuggestedArticles variant="full" />} {!hideAiSuggestions && <AISuggestedArticles variant="full" />}
<div className="bg-[#111] border border-[#222] p-4"> <div className="bg-[#111] border border-[#222] p-4">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-3 pb-2 border-b border-[#222]"> <h3 className="font-body font-bold text-xs text-[#E67E22] uppercase tracking-widest mb-3 pb-2 border-b border-[#222]">
Popular Tags Popular Tags
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -336,8 +336,8 @@ export default function NewsPage({
onClick={() => setActiveCategory(tag)} onClick={() => setActiveCategory(tag)}
className={`px-2 py-1 border text-xs font-body rounded-sm transition-colors ${ className={`px-2 py-1 border text-xs font-body rounded-sm transition-colors ${
activeCategory === tag activeCategory === tag
? "bg-[#3b82f6] border-[#3b82f6] text-white" ? "bg-[#E67E22] border-[#E67E22] text-white"
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]" : "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]"
}`} }`}
> >
{tag} {tag}
@@ -346,7 +346,7 @@ export default function NewsPage({
</div> </div>
</div> </div>
<div className="bg-[#0d1520] border border-[#1e3a5f] p-4"> <div className="bg-[#0d1520] border border-[#1e3a5f] p-4">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-2">Newsletter</h3> <h3 className="font-body font-bold text-xs text-[#E67E22] uppercase tracking-widest mb-2">Newsletter</h3>
<p className="text-[#777] text-xs font-body mb-3">Get broadcast news delivered to your inbox every week.</p> <p className="text-[#777] text-xs font-body mb-3">Get broadcast news delivered to your inbox every week.</p>
<Link href="/home-page#newsletter" className="btn-subscribe text-xs py-2 px-4 inline-block">Subscribe Free</Link> <Link href="/home-page#newsletter" className="btn-subscribe text-xs py-2 px-4 inline-block">Subscribe Free</Link>
</div> </div>

View File

@@ -10,10 +10,13 @@ import ArticleBody from "@/components/ArticleBody";
import StarRating from "@/components/StarRating"; import StarRating from "@/components/StarRating";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import type { Article } from "@/lib/articles/types"; import type { Article } from "@/lib/articles/types";
import type { RelatedForumThread } from "@/lib/articles/legacy-source";
import ArticleComments from "@/components/ArticleComments";
interface NewsArticleDetailClientProps { interface NewsArticleDetailClientProps {
article: Article; article: Article;
relatedArticles: Article[]; relatedArticles: Article[];
relatedForumThreads?: RelatedForumThread[];
companiesInStory?: React.ReactNode; companiesInStory?: React.ReactNode;
} }
@@ -30,6 +33,7 @@ function getSessionId(): string {
export default function NewsArticleDetailClient({ export default function NewsArticleDetailClient({
article, article,
relatedArticles, relatedArticles,
relatedForumThreads = [],
companiesInStory, companiesInStory,
}: NewsArticleDetailClientProps) { }: NewsArticleDetailClientProps) {
const [isSaved, setIsSaved] = useState(false); const [isSaved, setIsSaved] = useState(false);
@@ -229,7 +233,7 @@ export default function NewsArticleDetailClient({
setEmailSending(true); setEmailSending(true);
setEmailStatus("idle"); setEmailStatus("idle");
try { try {
const articleUrl = `${process.env.NEXT_PUBLIC_SITE_URL || "https://broadcastbeat.com"}/news/${article.slug}`; const articleUrl = `${process.env.NEXT_PUBLIC_SITE_URL || "https://avbeat.com"}/news/${article.slug}`;
const res = await fetch("/api/news/share-email", { const res = await fetch("/api/news/share-email", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -265,7 +269,7 @@ export default function NewsArticleDetailClient({
const articleUrl = const articleUrl =
typeof window !== "undefined" typeof window !== "undefined"
? window.location.href ? window.location.href
: `https://broadcastbeat.com/news/${article.slug}`; : `https://avbeat.com/news/${article.slug}`;
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -274,11 +278,11 @@ export default function NewsArticleDetailClient({
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="bg-[#0d0d0d] border-b border-[#1a1a1a]"> <div className="bg-[#0d0d0d] border-b border-[#1a1a1a]">
<div className="max-w-container mx-auto px-4 py-2.5 flex items-center gap-2 text-xs font-body text-[#555]"> <div className="max-w-container mx-auto px-4 py-2.5 flex items-center gap-2 text-xs font-body text-[#555]">
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors"> <Link href="/home-page" className="hover:text-[#E67E22] transition-colors">
Home Home
</Link> </Link>
<span>/</span> <span>/</span>
<Link href="/news" className="hover:text-[#3b82f6] transition-colors"> <Link href="/news" className="hover:text-[#E67E22] transition-colors">
News News
</Link> </Link>
<span>/</span> <span>/</span>
@@ -296,7 +300,7 @@ export default function NewsArticleDetailClient({
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Link <Link
href="/news" href="/news"
className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:underline"> className="text-xs font-bold uppercase tracking-widest text-[#E67E22] hover:underline">
{article.category} {article.category}
</Link> </Link>
<span className="text-xs text-[#777]">{article.readTime}</span> <span className="text-xs text-[#777]">{article.readTime}</span>
@@ -325,7 +329,7 @@ export default function NewsArticleDetailClient({
<div> <div>
<Link <Link
href={`/authors/${article.authorSlug}`} href={`/authors/${article.authorSlug}`}
className="font-heading font-bold text-sm hover:text-[#3b82f6] transition-colors text-[#e0e0e0]"> className="font-heading font-bold text-sm hover:text-[#E67E22] transition-colors text-[#e0e0e0]">
{article.author} {article.author}
</Link> </Link>
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p> <p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
@@ -339,7 +343,7 @@ export default function NewsArticleDetailClient({
onClick={() => setEmailShareOpen(true)} onClick={() => setEmailShareOpen(true)}
aria-label="Share via email" aria-label="Share via email"
title="Email" title="Email"
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#3b82f6] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#E67E22] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2" /> <rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /> <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
@@ -360,7 +364,7 @@ export default function NewsArticleDetailClient({
{/* X / Twitter */} {/* X / Twitter */}
<a <a
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=BroadcastBeat`} href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=AVBeat`}
target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"
aria-label="Share on X" aria-label="Share on X"
title="X (Twitter)" title="X (Twitter)"
@@ -414,7 +418,7 @@ export default function NewsArticleDetailClient({
}} }}
aria-label="Copy article link" aria-label="Copy article link"
title="Copy link" title="Copy link"
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#3b82f6] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#E67E22] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
@@ -428,7 +432,7 @@ export default function NewsArticleDetailClient({
onClick={handleSaveToReadingList} onClick={handleSaveToReadingList}
disabled={savingState !== "idle"} disabled={savingState !== "idle"}
aria-label={isSaved ? "Remove from reading list" : "Save to reading list"} aria-label={isSaved ? "Remove from reading list" : "Save to reading list"}
className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:text-blue-300 disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="text-xs font-bold uppercase tracking-widest text-[#E67E22] hover:text-blue-300 disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
{savingState === "saving" ?"Saving..." {savingState === "saving" ?"Saving..."
: savingState === "removing" ?"Removing..." : savingState === "removing" ?"Removing..."
: isSaved : isSaved
@@ -465,7 +469,7 @@ export default function NewsArticleDetailClient({
{article.tags.map((tag) => ( {article.tags.map((tag) => (
<span <span
key={tag} key={tag}
className="px-3 py-1 text-xs font-body bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6] transition-colors rounded-sm cursor-default"> className="px-3 py-1 text-xs font-body bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22] transition-colors rounded-sm cursor-default">
{tag} {tag}
</span> </span>
))} ))}
@@ -479,7 +483,7 @@ export default function NewsArticleDetailClient({
<p className="font-body text-sm text-[#777]">Found this article useful?</p> <p className="font-body text-sm text-[#777]">Found this article useful?</p>
<button <button
onClick={() => setEmailShareOpen(true)} onClick={() => setEmailShareOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] text-[#888] hover:text-[#3b82f6] text-xs font-bold uppercase tracking-widest transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="flex items-center gap-2 px-4 py-2 bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#E67E22] text-[#888] hover:text-[#E67E22] text-xs font-bold uppercase tracking-widest transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg <svg
width="13" width="13"
height="13" height="13"
@@ -500,7 +504,7 @@ export default function NewsArticleDetailClient({
<div className="pt-6 border-t border-[#222]"> <div className="pt-6 border-t border-[#222]">
<Link <Link
href="/news" href="/news"
className="inline-flex items-center gap-2 text-[#3b82f6] font-body text-sm hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="inline-flex items-center gap-2 text-[#E67E22] font-body text-sm hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg <svg
width="14" width="14"
height="14" height="14"
@@ -514,6 +518,10 @@ export default function NewsArticleDetailClient({
Back to News Back to News
</Link> </Link>
</div> </div>
{/* Article comments — sign-in gated for posting + voting,
public for reading. AI personas participate (tagged). */}
<ArticleComments articleSlug={article.slug} />
</div> </div>
{/* Sidebar */} {/* Sidebar */}
@@ -526,7 +534,7 @@ export default function NewsArticleDetailClient({
{/* Related Articles Sidebar */} {/* Related Articles Sidebar */}
<div className="bg-[#111] border border-[#222] p-5"> <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]"> <h3 className="font-body font-bold text-xs text-[#E67E22] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
Related Articles Related Articles
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
@@ -545,10 +553,10 @@ export default function NewsArticleDetailClient({
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[#3b82f6] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5"> <p className="text-[#E67E22] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5">
{related.category} {related.category}
</p> </p>
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug"> <p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#E67E22] transition-colors line-clamp-2 leading-snug">
{related.title} {related.title}
</p> </p>
</div> </div>
@@ -557,6 +565,42 @@ export default function NewsArticleDetailClient({
</div> </div>
</div> </div>
{/* Related Forum Posts — same widget as /articles/[slug] */}
{relatedForumThreads.length > 0 && (
<div className="bg-[#111] border border-[#222] p-5">
<h3 className="font-body font-bold text-xs text-[#E67E22] 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-[#E67E22] 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-[#E67E22] 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-[#E67E22] hover:underline font-body font-semibold uppercase tracking-wider"
>
Browse all forum threads
</Link>
</div>
)}
{/* Newsletter Signup */} {/* Newsletter Signup */}
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5"> <div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
<h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3> <h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3>
@@ -595,7 +639,7 @@ export default function NewsArticleDetailClient({
onClick={prevCarousel} onClick={prevCarousel}
disabled={carouselIndex === 0} disabled={carouselIndex === 0}
aria-label="Previous articles" aria-label="Previous articles"
className="w-8 h-8 flex items-center justify-center border border-[#2a2a2a] text-[#666] hover:border-[#3b82f6] hover:text-[#3b82f6] disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="w-8 h-8 flex items-center justify-center border border-[#2a2a2a] text-[#666] hover:border-[#E67E22] hover:text-[#E67E22] disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg <svg
width="14" width="14"
height="14" height="14"
@@ -611,7 +655,7 @@ export default function NewsArticleDetailClient({
onClick={nextCarousel} onClick={nextCarousel}
disabled={carouselIndex >= maxIndex} disabled={carouselIndex >= maxIndex}
aria-label="Next articles" aria-label="Next articles"
className="w-8 h-8 flex items-center justify-center border border-[#2a2a2a] text-[#666] hover:border-[#3b82f6] hover:text-[#3b82f6] disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="w-8 h-8 flex items-center justify-center border border-[#2a2a2a] text-[#666] hover:border-[#E67E22] hover:text-[#E67E22] disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg <svg
width="14" width="14"
height="14" height="14"
@@ -650,11 +694,11 @@ export default function NewsArticleDetailClient({
/> />
</div> </div>
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<span className="text-[#3b82f6] font-body text-[10px] font-bold uppercase tracking-wider"> <span className="text-[#E67E22] font-body text-[10px] font-bold uppercase tracking-wider">
{related.category} {related.category}
</span> </span>
</div> </div>
<h3 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug group-hover:text-[#3b82f6] transition-colors line-clamp-2 mb-1.5"> <h3 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug group-hover:text-[#E67E22] transition-colors line-clamp-2 mb-1.5">
{related.title} {related.title}
</h3> </h3>
<p className="text-[#666] font-body text-xs line-clamp-2 leading-relaxed"> <p className="text-[#666] font-body text-xs line-clamp-2 leading-relaxed">
@@ -679,7 +723,7 @@ export default function NewsArticleDetailClient({
onClick={() => setCarouselIndex(i)} onClick={() => setCarouselIndex(i)}
aria-label={`Go to slide ${i + 1}`} aria-label={`Go to slide ${i + 1}`}
className={`w-1.5 h-1.5 rounded-full transition-colors focus:outline-none ${ className={`w-1.5 h-1.5 rounded-full transition-colors focus:outline-none ${
i === carouselIndex ? "bg-[#3b82f6]" : "bg-[#333]" i === carouselIndex ? "bg-[#E67E22]" : "bg-[#333]"
}`} }`}
/> />
))} ))}
@@ -712,7 +756,7 @@ export default function NewsArticleDetailClient({
setEmailStatus("idle"); setEmailStatus("idle");
}} }}
aria-label="Close share dialog" aria-label="Close share dialog"
className="absolute top-4 right-4 text-[#555] hover:text-[#e0e0e0] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"> className="absolute top-4 right-4 text-[#555] hover:text-[#e0e0e0] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]">
<svg <svg
width="18" width="18"
height="18" height="18"
@@ -733,7 +777,7 @@ export default function NewsArticleDetailClient({
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="#3b82f6" stroke="#E67E22"
strokeWidth="2" strokeWidth="2"
aria-hidden="true"> aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2" /> <rect x="2" y="4" width="20" height="16" rx="2" />
@@ -779,7 +823,7 @@ export default function NewsArticleDetailClient({
<label <label
htmlFor="email-to" htmlFor="email-to"
className="block font-body text-xs text-[#888] uppercase tracking-wider mb-1.5"> className="block font-body text-xs text-[#888] uppercase tracking-wider mb-1.5">
Recipient Email <span className="text-[#3b82f6]">*</span> Recipient Email <span className="text-[#E67E22]">*</span>
</label> </label>
<input <input
id="email-to" id="email-to"
@@ -787,7 +831,7 @@ export default function NewsArticleDetailClient({
value={emailTo} value={emailTo}
onChange={(e) => setEmailTo(e.target.value)} onChange={(e) => setEmailTo(e.target.value)}
placeholder="colleague@example.com" placeholder="colleague@example.com"
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] focus:border-[#3b82f6] text-[#e0e0e0] placeholder-[#444] text-sm font-body px-3 py-2.5 rounded-sm outline-none transition-colors" className="w-full bg-[#0d0d0d] border border-[#2a2a2a] focus:border-[#E67E22] text-[#e0e0e0] placeholder-[#444] text-sm font-body px-3 py-2.5 rounded-sm outline-none transition-colors"
/> />
</div> </div>
<div> <div>
@@ -802,7 +846,7 @@ export default function NewsArticleDetailClient({
onChange={(e) => setEmailNote(e.target.value)} onChange={(e) => setEmailNote(e.target.value)}
placeholder="Thought you might find this interesting..." placeholder="Thought you might find this interesting..."
rows={3} rows={3}
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] focus:border-[#3b82f6] text-[#e0e0e0] placeholder-[#444] text-sm font-body px-3 py-2.5 rounded-sm outline-none transition-colors resize-none" className="w-full bg-[#0d0d0d] border border-[#2a2a2a] focus:border-[#E67E22] text-[#e0e0e0] placeholder-[#444] text-sm font-body px-3 py-2.5 rounded-sm outline-none transition-colors resize-none"
/> />
</div> </div>
@@ -846,7 +890,7 @@ export default function NewsArticleDetailClient({
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className="fixed bottom-4 right-4 bg-[#1a1a1a] border border-[#3b82f6] text-white px-4 py-3 rounded-sm text-sm font-body shadow-lg z-50"> className="fixed bottom-4 right-4 bg-[#1a1a1a] border border-[#E67E22] text-white px-4 py-3 rounded-sm text-sm font-body shadow-lg z-50">
{saveToast} {saveToast}
</div> </div>
)} )}

View File

@@ -5,6 +5,7 @@ import {
getLegacyArticleBySlug, getLegacyArticleBySlug,
getLegacyRecentSlugs, getLegacyRecentSlugs,
getLegacyRelatedArticles, getLegacyRelatedArticles,
getRelatedForumThreads, type RelatedForumThread,
} from "@/lib/articles/legacy-source"; } from "@/lib/articles/legacy-source";
import { linkifyAndExtractMentions } from "@/lib/company-mentions"; import { linkifyAndExtractMentions } from "@/lib/company-mentions";
import CompanyMentionsHover from "@/components/CompanyMentionsHover"; import CompanyMentionsHover from "@/components/CompanyMentionsHover";
@@ -21,7 +22,7 @@ interface PageProps {
} }
const SITE_URL = const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://broadcastbeat.com"; process.env.NEXT_PUBLIC_SITE_URL || "https://avbeat.com";
/** Convert a free-form date (e.g. "April 9, 2026") to ISO 8601 for JSON-LD. */ /** Convert a free-form date (e.g. "April 9, 2026") to ISO 8601 for JSON-LD. */
function toIso(d: string | null | undefined): string { function toIso(d: string | null | undefined): string {
@@ -40,11 +41,15 @@ function absoluteImage(src: string | undefined | null): string {
async function loadArticle(slug: string): Promise<{ async function loadArticle(slug: string): Promise<{
article: Article | null; article: Article | null;
related: Article[]; related: Article[];
relatedForumThreads: RelatedForumThread[];
}> { }> {
const imported = await getLegacyArticleBySlug(slug); const imported = await getLegacyArticleBySlug(slug);
if (!imported) return { article: null, related: [] }; if (!imported) return { article: null, related: [], relatedForumThreads: [] };
const related = await getLegacyRelatedArticles(slug, imported.category, 6); const [related, relatedForumThreads] = await Promise.all([
return { article: imported, related }; getLegacyRelatedArticles(slug, imported.category, 6),
getRelatedForumThreads(imported.title, 6),
]);
return { article: imported, related, relatedForumThreads };
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -54,7 +59,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
return { title: "Article Not Found", description: "The article you are looking for does not exist." }; return { title: "Article Not Found", description: "The article you are looking for does not exist." };
} }
return { return {
title: `${article.title}Broadcast Beat`, title: `${article.title}AV Beat`,
description: article.excerpt, description: article.excerpt,
alternates: { canonical: `/news/${article.slug}` }, alternates: { canonical: `/news/${article.slug}` },
openGraph: { openGraph: {
@@ -72,7 +77,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
title: article.title, title: article.title,
description: article.excerpt, description: article.excerpt,
images: [article.image], images: [article.image],
creator: "@Broadcast Beat", creator: "@AV Beat",
}, },
}; };
} }
@@ -86,7 +91,7 @@ export async function generateStaticParams() {
export default async function NewsArticlePage({ params }: PageProps) { export default async function NewsArticlePage({ params }: PageProps) {
const { slug } = await params; const { slug } = await params;
const { article, related } = await loadArticle(slug); const { article, related, relatedForumThreads } = await loadArticle(slug);
if (!article) { if (!article) {
// Unknown slug — fall back to the news index instead of 404. // Unknown slug — fall back to the news index instead of 404.
redirect("/news"); redirect("/news");
@@ -106,7 +111,7 @@ export default async function NewsArticlePage({ params }: PageProps) {
author: { "@type": "Person", name: article.author }, author: { "@type": "Person", name: article.author },
publisher: { publisher: {
"@type": "Organization", "@type": "Organization",
name: "Broadcast Beat", name: "AV Beat",
logo: { "@type": "ImageObject", url: `${SITE_URL}/assets/images/logo.png` }, logo: { "@type": "ImageObject", url: `${SITE_URL}/assets/images/logo.png` },
}, },
datePublished: toIso(article.date), datePublished: toIso(article.date),
@@ -123,6 +128,7 @@ export default async function NewsArticlePage({ params }: PageProps) {
<NewsArticleDetailClient <NewsArticleDetailClient
article={articleWithLinks} article={articleWithLinks}
relatedArticles={related} relatedArticles={related}
relatedForumThreads={relatedForumThreads}
companiesInStory={ companiesInStory={
mentionedSlugs.length > 0 ? ( mentionedSlugs.length > 0 ? (
<CompaniesInThisStory slugs={mentionedSlugs} /> <CompaniesInThisStory slugs={mentionedSlugs} />

View File

@@ -6,9 +6,9 @@ import { getLegacyFeaturedArticles } from "@/lib/articles/legacy-source";
export const revalidate = 1800; export const revalidate = 1800;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Featured Stories — Broadcast Beat", title: "Featured Stories — AV Beat",
description: description:
"Editorial picks from the Broadcast Beat staff — the most important industry stories, hand-selected from across broadcast engineering, IP/cloud workflows, streaming, AI, and live production.", "Editorial picks from the AV Beat staff — the most important industry stories, hand-selected from across broadcast engineering, IP/cloud workflows, streaming, AI, and live production.",
alternates: { canonical: "/news/featured" }, alternates: { canonical: "/news/featured" },
}; };
@@ -20,7 +20,7 @@ export default async function FeaturedNewsPage() {
articles={articles} articles={articles}
kicker="Editorial" kicker="Editorial"
heading="Featured Editorial" heading="Featured Editorial"
subtitle="Hand-picked feature stories, exclusives, and cover articles from the Broadcast Beat editorial desk." subtitle="Hand-picked feature stories, exclusives, and cover articles from the AV Beat editorial desk."
hideAiSuggestions hideAiSuggestions
/> />
</Suspense> </Suspense>

View File

@@ -6,7 +6,7 @@ import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
export const revalidate = 1800; export const revalidate = 1800;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Industry News — Broadcast Beat", title: "Industry News — AV Beat",
description: description:
"Breaking news and industry updates from the world of broadcast engineering, IP/cloud workflows, streaming, AI, and live production.", "Breaking news and industry updates from the world of broadcast engineering, IP/cloud workflows, streaming, AI, and live production.",
alternates: { canonical: "/news" }, alternates: { canonical: "/news" },

View File

@@ -63,7 +63,7 @@ function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
const blocks = Array.isArray(newsletter.article_blocks) const blocks = Array.isArray(newsletter.article_blocks)
? newsletter.article_blocks.filter((b) => b.title) ? newsletter.article_blocks.filter((b) => b.title)
: []; : [];
const accent = newsletter.accent_color || "#3b82f6"; const accent = newsletter.accent_color || "#E67E22";
const layoutLabel: Record<string, string> = { const layoutLabel: Record<string, string> = {
featured: "Weekly Digest", featured: "Weekly Digest",
@@ -113,7 +113,7 @@ function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
<span className="mt-1.5 w-1 h-1 rounded-full bg-[#444] flex-shrink-0" /> <span className="mt-1.5 w-1 h-1 rounded-full bg-[#444] flex-shrink-0" />
<span className="font-body text-[#888] text-xs leading-relaxed line-clamp-1"> <span className="font-body text-[#888] text-xs leading-relaxed line-clamp-1">
{block.category && ( {block.category && (
<span className="text-[#3b82f6] font-bold mr-1">{block.category}:</span> <span className="text-[#E67E22] font-bold mr-1">{block.category}:</span>
)} )}
{block.title} {block.title}
</span> </span>
@@ -132,7 +132,7 @@ function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
<span className="font-body text-[#555] text-xs"> <span className="font-body text-[#555] text-xs">
{blocks.length} {blocks.length === 1 ? "story" : "stories"} {blocks.length} {blocks.length === 1 ? "story" : "stories"}
</span> </span>
<span className="font-body text-[#3b82f6] text-xs font-medium"> <span className="font-body text-[#E67E22] text-xs font-medium">
Issue #{newsletter.id.slice(0, 6).toUpperCase()} Issue #{newsletter.id.slice(0, 6).toUpperCase()}
</span> </span>
</div> </div>
@@ -197,9 +197,9 @@ function SignupCTA() {
{status === "success" ? ( {status === "success" ? (
<div className="text-center py-4" role="status" aria-live="polite"> <div className="text-center py-4" role="status" aria-live="polite">
<div className="w-10 h-10 rounded-full bg-[#3b82f6]/20 flex items-center justify-center mx-auto mb-3"> <div className="w-10 h-10 rounded-full bg-[#E67E22]/20 flex items-center justify-center mx-auto mb-3">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M4 10l4 4 8-8" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M4 10l4 4 8-8" stroke="#E67E22" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div> </div>
<p className="font-heading text-[#e0e0e0] font-bold mb-1">You&apos;re subscribed!</p> <p className="font-heading text-[#e0e0e0] font-bold mb-1">You&apos;re subscribed!</p>
@@ -228,7 +228,7 @@ function SignupCTA() {
/> />
<span <span
aria-hidden="true" aria-hidden="true"
className={`mr-1.5 inline-block w-3 h-3 rounded-sm border transition-colors ${checked ? "bg-[#3b82f6] border-[#3b82f6]" : "border-[#444]"}`}> className={`mr-1.5 inline-block w-3 h-3 rounded-sm border transition-colors ${checked ? "bg-[#E67E22] border-[#E67E22]" : "border-[#444]"}`}>
{checked && ( {checked && (
<svg viewBox="0 0 12 12" fill="none" className="w-full h-full" aria-hidden="true"> <svg viewBox="0 0 12 12" fill="none" className="w-full h-full" aria-hidden="true">
<path d="M2 6l3 3 5-5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> <path d="M2 6l3 3 5-5" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -262,7 +262,7 @@ function SignupCTA() {
<button <button
type="submit" type="submit"
disabled={status === "loading"} disabled={status === "loading"}
className={`btn-subscribe py-2.5 px-6 text-sm whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${status === "loading" ? "opacity-70 cursor-not-allowed" : ""}`}> className={`btn-subscribe py-2.5 px-6 text-sm whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${status === "loading" ? "opacity-70 cursor-not-allowed" : ""}`}>
{status === "loading" ? "Subscribing..." : "Subscribe Free"} {status === "loading" ? "Subscribing..." : "Subscribe Free"}
</button> </button>
</div> </div>
@@ -275,7 +275,7 @@ function SignupCTA() {
<p className="font-body text-[#555] text-xs mt-3 text-center"> <p className="font-body text-[#555] text-xs mt-3 text-center">
No spam. Unsubscribe anytime.{" "} No spam. Unsubscribe anytime.{" "}
<Link href="/privacy" className="text-[#3b82f6] hover:underline"> <Link href="/privacy" className="text-[#E67E22] hover:underline">
Privacy Policy Privacy Policy
</Link> </Link>
</p> </p>
@@ -379,7 +379,7 @@ export default function NewsletterArchivePage() {
</div> </div>
<h1 className="font-heading text-white text-3xl font-bold">Newsletter Archive</h1> <h1 className="font-heading text-white text-3xl font-bold">Newsletter Archive</h1>
<p className="text-[#777] font-body text-sm mt-2"> <p className="text-[#777] font-body text-sm mt-2">
Browse past issues of the Broadcast Beat newsletter searchable by date and topic. Browse past issues of the AV Beat newsletter searchable by date and topic.
</p> </p>
</div> </div>
</div> </div>
@@ -419,9 +419,9 @@ export default function NewsletterArchivePage() {
<button <button
key={topic} key={topic}
onClick={() => setSelectedTopic(topic)} onClick={() => setSelectedTopic(topic)}
className={`font-body text-xs px-3 py-1.5 rounded-sm border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`font-body text-xs px-3 py-1.5 rounded-sm border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
selectedTopic === topic selectedTopic === topic
? "bg-[#3b82f6] border-[#3b82f6] text-white" ? "bg-[#E67E22] border-[#E67E22] text-white"
: "bg-transparent border-[#333] text-[#888] hover:border-[#555] hover:text-[#ccc]" : "bg-transparent border-[#333] text-[#888] hover:border-[#555] hover:text-[#ccc]"
}`} }`}
aria-pressed={selectedTopic === topic}> aria-pressed={selectedTopic === topic}>
@@ -462,7 +462,7 @@ export default function NewsletterArchivePage() {
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={handleClearFilters} onClick={handleClearFilters}
className="font-body text-xs text-[#3b82f6] hover:text-[#60a5fa] transition-colors underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]"> className="font-body text-xs text-[#E67E22] hover:text-[#E67E22] transition-colors underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]">
Clear filters Clear filters
</button> </button>
)} )}
@@ -498,7 +498,7 @@ export default function NewsletterArchivePage() {
<p className="font-body text-red-400 text-sm mb-3">{error}</p> <p className="font-body text-red-400 text-sm mb-3">{error}</p>
<button <button
onClick={fetchArchive} onClick={fetchArchive}
className="font-body text-xs text-[#3b82f6] hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]"> className="font-body text-xs text-[#E67E22] hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]">
Try again Try again
</button> </button>
</div> </div>
@@ -521,7 +521,7 @@ export default function NewsletterArchivePage() {
</p> </p>
<button <button
onClick={handleClearFilters} onClick={handleClearFilters}
className="font-body text-xs text-[#3b82f6] hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]"> className="font-body text-xs text-[#E67E22] hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]">
Clear all filters Clear all filters
</button> </button>
</div> </div>
@@ -555,9 +555,9 @@ export default function NewsletterArchivePage() {
<li key={topic}> <li key={topic}>
<button <button
onClick={() => setSelectedTopic(topic)} onClick={() => setSelectedTopic(topic)}
className={`w-full text-left font-body text-sm py-1.5 px-2 rounded-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`w-full text-left font-body text-sm py-1.5 px-2 rounded-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
selectedTopic === topic selectedTopic === topic
? "text-[#3b82f6] bg-[#3b82f6]/10" ? "text-[#E67E22] bg-[#E67E22]/10"
: "text-[#888] hover:text-[#ccc] hover:bg-[#1a1a1a]" : "text-[#888] hover:text-[#ccc] hover:bg-[#1a1a1a]"
}`}> }`}>
{topic} {topic}
@@ -578,9 +578,9 @@ export default function NewsletterArchivePage() {
<li key={year}> <li key={year}>
<button <button
onClick={() => { setSelectedYear(String(year)); setSelectedMonth(""); }} onClick={() => { setSelectedYear(String(year)); setSelectedMonth(""); }}
className={`w-full text-left font-body text-sm py-1.5 px-2 rounded-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${ className={`w-full text-left font-body text-sm py-1.5 px-2 rounded-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] ${
selectedYear === String(year) selectedYear === String(year)
? "text-[#3b82f6] bg-[#3b82f6]/10" ? "text-[#E67E22] bg-[#E67E22]/10"
: "text-[#888] hover:text-[#ccc] hover:bg-[#1a1a1a]" : "text-[#888] hover:text-[#ccc] hover:bg-[#1a1a1a]"
}`}> }`}>
{year} {year}

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export const metadata = { export const metadata = {
title: "Newsletter — Broadcast Beat", title: "Newsletter — AV Beat",
description: "Broadcast Beat newsletter archive.", description: "AV Beat newsletter archive.",
alternates: { canonical: "/newsletter/archive" }, alternates: { canonical: "/newsletter/archive" },
}; };

View File

@@ -4,8 +4,8 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy Policy — Broadcast Beat", title: "Privacy Policy — AV Beat",
description: "Broadcast Beat Privacy Policy — how we collect, use, and protect your personal information. GDPR and CCPA compliant.", description: "AV Beat Privacy Policy — how we collect, use, and protect your personal information. GDPR and CCPA compliant.",
alternates: { alternates: {
canonical: "/privacy", canonical: "/privacy",
}, },
@@ -26,7 +26,7 @@ export default function PrivacyPage() {
<section aria-labelledby="section-intro"> <section aria-labelledby="section-intro">
<p id="section-intro"> <p id="section-intro">
This Privacy Policy describes how <strong className="text-[#e0e0e0]">Relevant Media Properties, LLC</strong> ("we," "us," or "our"), publisher of Broadcast Beat, AV Beat, Backlot Beat, and BroadcastEngineering.com, collects, uses, and shares information about you when you use our websites and services. We are committed to protecting your privacy and complying with applicable data protection laws including GDPR and CCPA. This Privacy Policy describes how <strong className="text-[#e0e0e0]">Relevant Media Properties, LLC</strong> ("we," "us," or "our"), publisher of AV Beat, AV Beat, Backlot Beat, and BroadcastEngineering.com, collects, uses, and shares information about you when you use our websites and services. We are committed to protecting your privacy and complying with applicable data protection laws including GDPR and CCPA.
</p> </p>
</section> </section>
@@ -103,7 +103,7 @@ export default function PrivacyPage() {
<li><strong className="text-[#ccc]">Opt-out (CCPA):</strong> California residents may opt out of the sale of personal information</li> <li><strong className="text-[#ccc]">Opt-out (CCPA):</strong> California residents may opt out of the sale of personal information</li>
<li><strong className="text-[#ccc]">Unsubscribe:</strong> Opt out of marketing emails at any time via the unsubscribe link</li> <li><strong className="text-[#ccc]">Unsubscribe:</strong> Opt out of marketing emails at any time via the unsubscribe link</li>
</ul> </ul>
<p className="mt-3">To exercise any of these rights, contact us at <a href="mailto:privacy@broadcastbeat.com" className="text-[#3b82f6] underline hover:text-blue-400 transition-colors">privacy@broadcastbeat.com</a>. We will respond within 30 days.</p> <p className="mt-3">To exercise any of these rights, contact us at <a href="mailto:privacy@avbeat.com" className="text-[#E67E22] underline hover:text-blue-400 transition-colors">privacy@avbeat.com</a>. We will respond within 30 days.</p>
</section> </section>
<section aria-labelledby="section-6"> <section aria-labelledby="section-6">
@@ -127,7 +127,7 @@ export default function PrivacyPage() {
<address className="not-italic mt-3 space-y-1 text-[#999]"> <address className="not-italic mt-3 space-y-1 text-[#999]">
<p><strong className="text-[#ccc]">Relevant Media Properties, LLC</strong></p> <p><strong className="text-[#ccc]">Relevant Media Properties, LLC</strong></p>
<p>Privacy Officer</p> <p>Privacy Officer</p>
<p>Email: <a href="mailto:privacy@broadcastbeat.com" className="text-[#3b82f6] underline hover:text-blue-400 transition-colors">privacy@broadcastbeat.com</a></p> <p>Email: <a href="mailto:privacy@avbeat.com" className="text-[#E67E22] underline hover:text-blue-400 transition-colors">privacy@avbeat.com</a></p>
</address> </address>
</section> </section>

View File

@@ -82,7 +82,7 @@ export default function RegisterPage() {
<div className="min-h-screen bg-[#111111]"> <div className="min-h-screen bg-[#111111]">
<Header /> <Header />
<div className="flex items-center justify-center py-24"> <div className="flex items-center justify-center py-24">
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-[#E67E22] border-t-transparent rounded-full animate-spin" />
</div> </div>
<Footer /> <Footer />
</div> </div>
@@ -97,7 +97,7 @@ export default function RegisterPage() {
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e]"> <div className="bg-[#0d0d0d] border-b border-[#1e1e1e]">
<div className="max-w-container mx-auto px-4 py-2.5"> <div className="max-w-container mx-auto px-4 py-2.5">
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-xs font-body text-[#555]"> <nav aria-label="Breadcrumb" className="flex items-center gap-2 text-xs font-body text-[#555]">
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors">Home</Link> <Link href="/home-page" className="hover:text-[#E67E22] transition-colors">Home</Link>
<span aria-hidden="true">/</span> <span aria-hidden="true">/</span>
<span className="text-[#888]">Create Account</span> <span className="text-[#888]">Create Account</span>
</nav> </nav>
@@ -112,7 +112,7 @@ export default function RegisterPage() {
Create Account Create Account
</h1> </h1>
<p className="font-body text-sm text-[#666]"> <p className="font-body text-sm text-[#666]">
Join Broadcast Beat to save articles, track your reading history, and get personalized content. Join AV Beat to save articles, track your reading history, and get personalized content.
</p> </p>
</div> </div>
@@ -142,7 +142,7 @@ export default function RegisterPage() {
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value)}
placeholder="Jane Smith" placeholder="Jane Smith"
disabled={loading} disabled={loading}
className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#3b82f6] focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors disabled:opacity-50" className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#E67E22] focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors disabled:opacity-50"
/> />
</div> </div>
@@ -159,7 +159,7 @@ export default function RegisterPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com" placeholder="you@example.com"
disabled={loading} disabled={loading}
className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#3b82f6] focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors disabled:opacity-50" className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#E67E22] focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors disabled:opacity-50"
/> />
</div> </div>
@@ -176,7 +176,7 @@ export default function RegisterPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 6 characters" placeholder="Min. 6 characters"
disabled={loading} disabled={loading}
className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#3b82f6] focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors disabled:opacity-50" className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#E67E22] focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors disabled:opacity-50"
/> />
</div> </div>
@@ -193,7 +193,7 @@ export default function RegisterPage() {
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password" placeholder="Re-enter password"
disabled={loading} disabled={loading}
className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#3b82f6] focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors disabled:opacity-50" className="w-full h-10 px-3 bg-[#0d0d0d] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm rounded-sm placeholder-[#444] focus:outline-none focus:border-[#E67E22] focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors disabled:opacity-50"
/> />
</div> </div>
@@ -201,7 +201,7 @@ export default function RegisterPage() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full h-10 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-sm font-bold uppercase tracking-wider rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"> className="w-full h-10 bg-[#E67E22] hover:bg-[#C8651B] text-white font-body text-sm font-bold uppercase tracking-wider rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
{loading ? ( {loading ? (
<> <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
@@ -223,7 +223,7 @@ export default function RegisterPage() {
{/* Login Link */} {/* Login Link */}
<Link <Link
href="/login" href="/login"
className="block w-full h-10 border border-[#2a2a2a] hover:border-[#3b82f6] text-[#888] hover:text-[#3b82f6] font-body text-sm font-bold uppercase tracking-wider rounded-sm transition-colors flex items-center justify-center"> className="block w-full h-10 border border-[#2a2a2a] hover:border-[#E67E22] text-[#888] hover:text-[#E67E22] font-body text-sm font-bold uppercase tracking-wider rounded-sm transition-colors flex items-center justify-center">
Sign In Sign In
</Link> </Link>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com'; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
return { return {
rules: [ rules: [

View File

@@ -11,9 +11,9 @@ import SidebarAdStack from "@/components/SidebarAdStack";
export const revalidate = 60; export const revalidate = 60;
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Search — Broadcast Beat", title: "Search — AV Beat",
description: description:
"Search Broadcast Beat for broadcast engineering news, gear reviews, show coverage, and technology insights.", "Search AV Beat for broadcast engineering news, gear reviews, show coverage, and technology insights.",
alternates: { canonical: "/search" }, alternates: { canonical: "/search" },
}; };
@@ -36,7 +36,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<div className="flex-1 h-px bg-[#2a2a2a]" /> <div className="flex-1 h-px bg-[#2a2a2a]" />
</div> </div>
<h1 className="font-heading text-white text-3xl font-bold"> <h1 className="font-heading text-white text-3xl font-bold">
{query ? `Results for "${query}"` : "Search Broadcast Beat"} {query ? `Results for "${query}"` : "Search AV Beat"}
</h1> </h1>
<p className="text-[#777] font-body text-sm mt-2"> <p className="text-[#777] font-body text-sm mt-2">
{query {query
@@ -51,7 +51,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
{query && results.length === 0 && ( {query && results.length === 0 && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<p className="text-[#555] font-body text-sm mb-3">No articles match your search.</p> <p className="text-[#555] font-body text-sm mb-3">No articles match your search.</p>
<Link href="/news" className="text-[#3b82f6] text-xs font-body hover:underline"> <Link href="/news" className="text-[#E67E22] text-xs font-body hover:underline">
Browse all news Browse all news
</Link> </Link>
</div> </div>
@@ -69,7 +69,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm"> <div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm">
<AppImage <AppImage
src={article.image || "/assets/images/article-placeholder.jpg"} src={article.image || "/assets/images/article-placeholder.jpg"}
alt={article.alt || article.title || "Broadcast Beat article"} alt={article.alt || article.title || "AV Beat article"}
fill fill
className="object-cover group-hover:scale-105 transition-transform duration-300" className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="160px" sizes="160px"
@@ -77,18 +77,18 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<span className="text-[#3b82f6] font-body text-[10px] font-bold uppercase tracking-wider"> <span className="text-[#E67E22] font-body text-[10px] font-bold uppercase tracking-wider">
{article.category || "NEWS"} {article.category || "NEWS"}
</span> </span>
</div> </div>
<h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-2"> <h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#E67E22] transition-colors line-clamp-2">
{article.title || "Untitled"} {article.title || "Untitled"}
</h2> </h2>
<p className="text-[#777] font-body text-sm leading-relaxed line-clamp-2"> <p className="text-[#777] font-body text-sm leading-relaxed line-clamp-2">
{article.excerpt || ""} {article.excerpt || ""}
</p> </p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className="text-[#555] font-body text-xs">By {article.author || "Broadcast Beat"}</span> <span className="text-[#555] font-body text-xs">By {article.author || "AV Beat"}</span>
<span className="text-[#444] text-[10px]">·</span> <span className="text-[#444] text-[10px]">·</span>
<span className="text-[#555] font-body text-xs">{article.readTime || ""}</span> <span className="text-[#555] font-body text-xs">{article.readTime || ""}</span>
</div> </div>

View File

@@ -2,7 +2,7 @@ import { MetadataRoute } from 'next';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { getLegacyRecentSitemapEntries } from '@/lib/articles/legacy-source'; import { getLegacyRecentSitemapEntries } from '@/lib/articles/legacy-source';
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com'; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
// Cap below Google's 50k URL/sitemap hard limit, leaving room for the // Cap below Google's 50k URL/sitemap hard limit, leaving room for the
// static + author + event entries we add below. // static + author + event entries we add below.

View File

@@ -49,7 +49,7 @@ interface PageProps {
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> { export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const sp = await searchParams; const sp = await searchParams;
const cat = sp?.category && CATEGORY_FILTERS[sp.category]; const cat = sp?.category && CATEGORY_FILTERS[sp.category];
const title = cat ? `${cat.title}Broadcast Beat` : "Production Technology Deep Dives — Broadcast Beat"; const title = cat ? `${cat.title}AV Beat` : "Production Technology Deep Dives — AV Beat";
const desc = cat?.description || "Deep-dive analysis of broadcast technology trends: IP workflows, cloud production, AI automation, streaming infrastructure, and ATSC 3.0."; const desc = cat?.description || "Deep-dive analysis of broadcast technology trends: IP workflows, cloud production, AI automation, streaming infrastructure, and ATSC 3.0.";
return { return {
title, title,
@@ -87,7 +87,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
<span className="section-label">Technology</span> <span className="section-label">Technology</span>
<div className="flex-1 h-px bg-[#2a2a2a]" /> <div className="flex-1 h-px bg-[#2a2a2a]" />
{filter && ( {filter && (
<Link href="/technology" className="text-[#666] hover:text-[#3b82f6] font-body text-[11px] underline"> <Link href="/technology" className="text-[#666] hover:text-[#E67E22] font-body text-[11px] underline">
clear filter clear filter
</Link> </Link>
)} )}
@@ -101,7 +101,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
href="/technology" href="/technology"
className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all ${ className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all ${
!filterSlug !filterSlug
? "bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/50" ? "bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/50"
: "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc]" : "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc]"
}`}> }`}>
All All
@@ -114,7 +114,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
href={`/technology?category=${slug}`} href={`/technology?category=${slug}`}
className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all ${ className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all ${
isActive isActive
? "bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/50" ? "bg-[#E67E22]/20 text-[#E67E22] border border-[#E67E22]/50"
: "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc]" : "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc]"
}`}> }`}>
{def.title} {def.title}
@@ -133,7 +133,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
No articles found{filter ? ` for "${filter.title}"` : ""}. No articles found{filter ? ` for "${filter.title}"` : ""}.
</p> </p>
{filter && ( {filter && (
<Link href="/technology" className="inline-block mt-3 text-[#3b82f6] hover:underline font-body text-sm"> <Link href="/technology" className="inline-block mt-3 text-[#E67E22] hover:underline font-body text-sm">
Back to all technology coverage Back to all technology coverage
</Link> </Link>
)} )}
@@ -145,7 +145,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
<div className="mb-10"> <div className="mb-10">
<Link <Link
href={`/articles/${articles[0].slug}`} href={`/articles/${articles[0].slug}`}
className="flex flex-col md:flex-row gap-6 bg-[#111] border border-[#222] overflow-hidden group hover:border-[#3b82f6] transition-colors p-5"> className="flex flex-col md:flex-row gap-6 bg-[#111] border border-[#222] overflow-hidden group hover:border-[#E67E22] transition-colors p-5">
<div className="flex-shrink-0 relative w-full md:w-[320px] h-[200px] md:h-[200px] overflow-hidden rounded-sm"> <div className="flex-shrink-0 relative w-full md:w-[320px] h-[200px] md:h-[200px] overflow-hidden rounded-sm">
<AppImage <AppImage
src={articles[0].image} src={articles[0].image}
@@ -157,13 +157,13 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-[#3b82f6] font-body text-[10px] font-bold uppercase tracking-wider">{articles[0].category}</span> <span className="text-[#E67E22] font-body text-[10px] font-bold uppercase tracking-wider">{articles[0].category}</span>
<span className="text-[#444] text-[10px]">·</span> <span className="text-[#444] text-[10px]">·</span>
<span className="text-[#555] font-body text-[11px]">{articles[0].date}</span> <span className="text-[#555] font-body text-[11px]">{articles[0].date}</span>
<span className="text-[#444] text-[10px]">·</span> <span className="text-[#444] text-[10px]">·</span>
<span className="text-[#555] font-body text-[11px]">{articles[0].readTime}</span> <span className="text-[#555] font-body text-[11px]">{articles[0].readTime}</span>
</div> </div>
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold leading-tight mb-3 group-hover:text-[#3b82f6] transition-colors"> <h2 className="font-heading text-[#e0e0e0] text-xl font-bold leading-tight mb-3 group-hover:text-[#E67E22] transition-colors">
{articles[0].title} {articles[0].title}
</h2> </h2>
<p className="text-[#777] font-body text-sm leading-relaxed mb-4">{articles[0].excerpt}</p> <p className="text-[#777] font-body text-sm leading-relaxed mb-4">{articles[0].excerpt}</p>
@@ -185,7 +185,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
<Link <Link
key={article.slug} key={article.slug}
href={`/articles/${article.slug}`} href={`/articles/${article.slug}`}
className="flex gap-4 bg-[#111] border border-[#222] p-4 group hover:border-[#3b82f6] transition-colors"> className="flex gap-4 bg-[#111] border border-[#222] p-4 group hover:border-[#E67E22] transition-colors">
<div className="flex-shrink-0 w-[120px] h-[80px] relative overflow-hidden rounded-sm"> <div className="flex-shrink-0 w-[120px] h-[80px] relative overflow-hidden rounded-sm">
<AppImage <AppImage
src={article.image} src={article.image}
@@ -197,11 +197,11 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-[#3b82f6] font-body text-[9px] font-bold uppercase tracking-wider">{article.category}</span> <span className="text-[#E67E22] font-body text-[9px] font-bold uppercase tracking-wider">{article.category}</span>
<span className="text-[#444] text-[9px]">·</span> <span className="text-[#444] text-[9px]">·</span>
<span className="text-[#555] font-body text-[10px]">{article.readTime}</span> <span className="text-[#555] font-body text-[10px]">{article.readTime}</span>
</div> </div>
<h2 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug mb-1 group-hover:text-[#3b82f6] transition-colors line-clamp-2"> <h2 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug mb-1 group-hover:text-[#E67E22] transition-colors line-clamp-2">
{article.title} {article.title}
</h2> </h2>
{article.category !== "Featured" && ( {article.category !== "Featured" && (

View File

@@ -103,16 +103,16 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-4 h-4 text-[#3b82f6]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-[#E67E22]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.347.346A3.5 3.5 0 0114.5 20h-5a3.5 3.5 0 01-2.47-1.024l-.347-.346z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.347.346A3.5 3.5 0 0114.5 20h-5a3.5 3.5 0 01-2.47-1.024l-.347-.346z" />
</svg> </svg>
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest"> <h3 className="font-body font-bold text-xs text-[#E67E22] uppercase tracking-widest">
AI Picks For You AI Picks For You
</h3> </h3>
</div> </div>
<button <button
onClick={() => setShowTopicPicker(!showTopicPicker)} onClick={() => setShowTopicPicker(!showTopicPicker)}
className="text-[10px] font-body text-[#555] hover:text-[#3b82f6] transition-colors flex items-center gap-1" className="text-[10px] font-body text-[#555] hover:text-[#E67E22] transition-colors flex items-center gap-1"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
@@ -132,8 +132,8 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
onClick={() => toggleInterest(topic)} onClick={() => toggleInterest(topic)}
className={`px-2 py-0.5 text-[10px] font-body rounded-sm border transition-colors ${ className={`px-2 py-0.5 text-[10px] font-body rounded-sm border transition-colors ${
interests.includes(topic) interests.includes(topic)
? 'bg-[#3b82f6] border-[#3b82f6] text-white' ? 'bg-[#E67E22] border-[#E67E22] text-white'
: 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]' : 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]'
}`} }`}
> >
{topic} {topic}
@@ -142,7 +142,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
</div> </div>
<button <button
onClick={handleApplyTopics} onClick={handleApplyTopics}
className="text-[10px] font-body bg-[#3b82f6] text-white px-3 py-1 rounded-sm hover:bg-[#2563eb] transition-colors" className="text-[10px] font-body bg-[#E67E22] text-white px-3 py-1 rounded-sm hover:bg-[#C8651B] transition-colors"
> >
Update Suggestions Update Suggestions
</button> </button>
@@ -180,10 +180,10 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
/> />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[#3b82f6] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5"> <p className="text-[#E67E22] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5">
{article.category} {article.category}
</p> </p>
<p className="font-heading text-[#e0e0e0] text-xs font-semibold leading-snug line-clamp-2 group-hover:text-[#3b82f6] transition-colors"> <p className="font-heading text-[#e0e0e0] text-xs font-semibold leading-snug line-clamp-2 group-hover:text-[#E67E22] transition-colors">
{article.title} {article.title}
</p> </p>
</div> </div>
@@ -203,7 +203,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg className="w-4 h-4 text-[#3b82f6]" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-[#E67E22]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.347.346A3.5 3.5 0 0114.5 20h-5a3.5 3.5 0 01-2.47-1.024l-.347-.346z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.347.346A3.5 3.5 0 0114.5 20h-5a3.5 3.5 0 01-2.47-1.024l-.347-.346z" />
</svg> </svg>
<span className="section-label">AI Picks For You</span> <span className="section-label">AI Picks For You</span>
@@ -212,7 +212,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
</div> </div>
<button <button
onClick={() => setShowTopicPicker(!showTopicPicker)} onClick={() => setShowTopicPicker(!showTopicPicker)}
className="text-[11px] font-body text-[#555] hover:text-[#3b82f6] transition-colors flex items-center gap-1 ml-4 shrink-0" className="text-[11px] font-body text-[#555] hover:text-[#E67E22] transition-colors flex items-center gap-1 ml-4 shrink-0"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
@@ -232,8 +232,8 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
onClick={() => toggleInterest(topic)} onClick={() => toggleInterest(topic)}
className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${ className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${
interests.includes(topic) interests.includes(topic)
? 'bg-[#3b82f6] border-[#3b82f6] text-white' ? 'bg-[#E67E22] border-[#E67E22] text-white'
: 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]' : 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]'
}`} }`}
> >
{topic} {topic}
@@ -242,7 +242,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
</div> </div>
<button <button
onClick={handleApplyTopics} onClick={handleApplyTopics}
className="text-xs font-body bg-[#3b82f6] text-white px-4 py-1.5 rounded-sm hover:bg-[#2563eb] transition-colors" className="text-xs font-body bg-[#E67E22] text-white px-4 py-1.5 rounded-sm hover:bg-[#C8651B] transition-colors"
> >
Update Suggestions Update Suggestions
</button> </button>
@@ -280,11 +280,11 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
sizes="(max-width: 768px) 50vw, 25vw" sizes="(max-width: 768px) 50vw, 25vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<span className="absolute bottom-2 left-2 text-[9px] font-body font-bold text-white uppercase tracking-wider bg-[#3b82f6] px-1.5 py-0.5 rounded-sm"> <span className="absolute bottom-2 left-2 text-[9px] font-body font-bold text-white uppercase tracking-wider bg-[#E67E22] px-1.5 py-0.5 rounded-sm">
{article.category} {article.category}
</span> </span>
</div> </div>
<h3 className="font-heading text-[#e0e0e0] text-sm font-semibold leading-snug line-clamp-2 group-hover:text-[#3b82f6] transition-colors"> <h3 className="font-heading text-[#e0e0e0] text-sm font-semibold leading-snug line-clamp-2 group-hover:text-[#E67E22] transition-colors">
{article.title} {article.title}
</h3> </h3>
<p className="text-[#555] font-body text-[11px] mt-1">{article.readTime}</p> <p className="text-[#555] font-body text-[11px] mt-1">{article.readTime}</p>

View File

@@ -36,7 +36,7 @@ export default function AboutDropdown() {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-haspopup="true" aria-haspopup="true"
aria-expanded={open} aria-expanded={open}
className="bb-browse-item gap-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]" className="bb-browse-item gap-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]"
> >
<span>ABOUT</span> <span>ABOUT</span>
<span aria-hidden="true" className="text-[10px]"></span> <span aria-hidden="true" className="text-[10px]"></span>
@@ -54,7 +54,7 @@ export default function AboutDropdown() {
href={it.href} href={it.href}
role="menuitem" role="menuitem"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="flex items-center justify-between px-4 py-2 text-sm hover:bg-[#1a1a1a] hover:text-[var(--color-text-info,#60a5fa)] focus:outline-none focus-visible:bg-[#1a1a1a] transition-colors" className="flex items-center justify-between px-4 py-2 text-sm hover:bg-[#1a1a1a] hover:text-[var(--color-text-info,#E67E22)] focus:outline-none focus-visible:bg-[#1a1a1a] transition-colors"
> >
<span>{it.label}</span> <span>{it.label}</span>
<span aria-hidden="true" className="text-[10px] text-[#6b7280]"></span> <span aria-hidden="true" className="text-[10px] text-[#6b7280]"></span>

View File

@@ -0,0 +1,315 @@
"use client";
/**
* Article comments section. Renders under every news + articles detail
* page. Anonymous visitors see all comments + a "Sign in to comment"
* CTA. Signed-in members get a composer + reply + vote affordances.
*
* Real users and AI users (is_ai_seeded=true) participate in the same
* thread — the only visible difference is a small "AI" chip next to the
* AI display name, so readers know what they're interacting with.
*/
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
type Comment = {
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;
};
interface Props {
articleSlug: string;
}
function timeAgo(iso: string) {
const t = Date.now() - new Date(iso).getTime();
const m = Math.floor(t / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
export default function ArticleComments({ articleSlug }: Props) {
const supabase = createClient();
const [user, setUser] = useState<{ id: string; email?: string } | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [body, setBody] = useState("");
const [replyTo, setReplyTo] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [myVotes, setMyVotes] = useState<Record<string, number>>({});
// Hydrate user + comments
useEffect(() => {
supabase.auth.getUser().then(({ data }) => {
setUser(data.user ? { id: data.user.id, email: data.user.email } : null);
});
fetchComments();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [articleSlug]);
async function fetchComments() {
setLoading(true);
try {
const r = await fetch(`/api/comments?article_slug=${encodeURIComponent(articleSlug)}`);
const j = await r.json();
if (Array.isArray(j.comments)) setComments(j.comments);
} finally {
setLoading(false);
}
}
async function submit() {
if (!body.trim()) return;
if (!user) return;
setSubmitting(true);
setError(null);
try {
const r = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
article_slug: articleSlug,
content: body.trim(),
parent_comment_id: replyTo,
}),
});
const j = await r.json();
if (!r.ok) {
setError(j.error || "Failed to post comment");
} else {
setBody("");
setReplyTo(null);
await fetchComments();
}
} finally {
setSubmitting(false);
}
}
async function vote(commentId: string, value: 1 | -1) {
if (!user) return;
const current = myVotes[commentId] || 0;
const next = current === value ? 0 : value;
setMyVotes((m) => ({ ...m, [commentId]: next }));
// Optimistic counter update
setComments((cs) => cs.map((c) => {
if (c.id !== commentId) return c;
const upDelta = (next === 1 ? 1 : 0) - (current === 1 ? 1 : 0);
const dnDelta = (next === -1 ? 1 : 0) - (current === -1 ? 1 : 0);
return {
...c,
upvotes: Math.max(0, c.upvotes + upDelta),
downvotes: Math.max(0, c.downvotes + dnDelta),
vote_score: c.vote_score + upDelta - dnDelta,
};
}));
try {
await fetch(`/api/comments/${commentId}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote: next }),
});
} catch { /* swallow — UI already updated optimistically */ }
}
// Build threaded tree
const tree = useMemo(() => {
const roots: Comment[] = [];
const childrenMap = new Map<string, Comment[]>();
for (const c of comments) {
if (c.parent_comment_id) {
const arr = childrenMap.get(c.parent_comment_id) || [];
arr.push(c);
childrenMap.set(c.parent_comment_id, arr);
} else {
roots.push(c);
}
}
return { roots, childrenMap };
}, [comments]);
const total = comments.length;
return (
<section className="mt-10 border-t border-[#252525] pt-8">
<header className="flex items-center justify-between mb-5">
<h2 className="font-heading text-white text-xl font-bold">
Discussion {total > 0 && <span className="text-[#666] font-normal">· {total}</span>}
</h2>
</header>
{/* Composer or sign-in CTA */}
{user ? (
<div className="bg-[#0d0d0d] border border-[#252525] rounded-md p-4 mb-6">
{replyTo && (
<div className="flex items-center justify-between mb-2 text-xs text-[#888]">
<span>Replying to comment</span>
<button onClick={() => setReplyTo(null)} className="text-[#E67E22] hover:underline">Cancel reply</button>
</div>
)}
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={replyTo ? "Write your reply…" : "Share your take on this story…"}
rows={3}
className="w-full bg-[#111] border border-[#252525] rounded text-white text-sm font-body p-2 resize-none focus:outline-none focus:border-[#E67E22]"
/>
{error && <p className="text-[11px] text-red-400 mt-1">{error}</p>}
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-[#666]">Markdown + image links supported · 5000 char max</span>
<button
onClick={submit}
disabled={submitting || !body.trim()}
className="bg-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body text-xs font-semibold px-4 py-1.5 rounded transition-colors"
>
{submitting ? "Posting…" : replyTo ? "Post reply" : "Post comment"}
</button>
</div>
</div>
) : (
<div className="bg-gradient-to-r from-[#0d1520] to-[#0d0d0d] border border-[#1e3a5f] rounded-md p-5 mb-6 text-center">
<p className="text-white font-body text-sm mb-3">
Sign in to join the discussion. Vote, reply, and follow industry conversations.
</p>
<div className="flex gap-3 justify-center">
<Link
href={`/forum/login?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
className="bg-[#E67E22] hover:bg-[#C8651B] text-white font-body text-xs font-semibold px-5 py-2 rounded"
>
Sign in
</Link>
<Link
href={`/forum/register?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
className="border border-[#E67E22] text-[#E67E22] hover:bg-[#E67E22]/10 font-body text-xs font-semibold px-5 py-2 rounded"
>
Create account
</Link>
</div>
</div>
)}
{/* Comment list */}
{loading ? (
<p className="text-[#666] text-sm">Loading comments</p>
) : tree.roots.length === 0 ? (
<p className="text-[#666] text-sm italic">No comments yet be the first.</p>
) : (
<ul className="space-y-5">
{tree.roots.map((c) => (
<CommentItem
key={c.id}
comment={c}
children={tree.childrenMap.get(c.id) || []}
canInteract={!!user}
onReply={(id) => { setReplyTo(id); document.querySelector("textarea")?.focus(); }}
onVote={vote}
myVote={myVotes[c.id] || 0}
myChildVotes={myVotes}
/>
))}
</ul>
)}
</section>
);
}
function CommentItem({
comment, children, canInteract, onReply, onVote, myVote, myChildVotes,
}: {
comment: Comment;
children: Comment[];
canInteract: boolean;
onReply: (id: string) => void;
onVote: (id: string, v: 1 | -1) => void;
myVote: number;
myChildVotes: Record<string, number>;
}) {
return (
<li className="bg-[#0d0d0d] border border-[#252525] rounded-md p-4">
<header className="flex items-center gap-2 mb-2">
{comment.author_avatar_url ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={comment.author_avatar_url} alt="" className="w-7 h-7 rounded-full bg-[#1a1a1a] object-cover flex-shrink-0" />
) : (
<div className="w-7 h-7 rounded-full bg-[#1a2535] text-[#E67E22] text-xs font-bold flex items-center justify-center flex-shrink-0">
{(comment.author_display_name || "?").trim().charAt(0).toUpperCase()}
</div>
)}
<div className="text-sm font-semibold text-white">
{comment.author_display_name || "Member"}
{comment.is_ai_seeded && (
<span className="ml-1.5 text-[9px] uppercase tracking-widest bg-[#1e3a5f] text-[#E67E22] px-1.5 py-0.5 rounded">AI</span>
)}
</div>
<span className="text-[11px] text-[#666]">· {timeAgo(comment.created_at)}</span>
</header>
<div className="text-[#cbd5e1] text-sm whitespace-pre-wrap leading-relaxed">{comment.content}</div>
<footer className="mt-2.5 flex items-center gap-3 text-xs">
<div className="inline-flex items-center gap-1">
<button
disabled={!canInteract}
onClick={() => onVote(comment.id, 1)}
title={canInteract ? "Upvote" : "Sign in to vote"}
className={`px-1.5 py-0.5 rounded text-base leading-none disabled:cursor-not-allowed disabled:opacity-50 ${
myVote === 1 ? "text-emerald-400" : "text-[#666] hover:text-emerald-400"
}`}
>
</button>
<span className="font-mono text-[#888] text-[11px] min-w-[18px] text-center">{comment.vote_score}</span>
<button
disabled={!canInteract}
onClick={() => onVote(comment.id, -1)}
title={canInteract ? "Downvote" : "Sign in to vote"}
className={`px-1.5 py-0.5 rounded text-base leading-none disabled:cursor-not-allowed disabled:opacity-50 ${
myVote === -1 ? "text-red-400" : "text-[#666] hover:text-red-400"
}`}
>
</button>
</div>
{canInteract && (
<button onClick={() => onReply(comment.id)} className="text-[#E67E22] hover:underline text-[11px]">
Reply
</button>
)}
</footer>
{children.length > 0 && (
<ul className="mt-4 space-y-3 pl-5 border-l border-[#252525]">
{children.map((kid) => (
<CommentItem
key={kid.id}
comment={kid}
children={[]}
canInteract={canInteract}
onReply={onReply}
onVote={onVote}
myVote={myChildVotes[kid.id] || 0}
myChildVotes={myChildVotes}
/>
))}
</ul>
)}
</li>
);
}

View File

@@ -15,7 +15,7 @@ export default function AskBBAI() {
{ {
role: "assistant", role: "assistant",
content: content:
"Hi — I'm BB AI. Ask me anything about broadcast, production, or post technology. I'll cite sources from the Broadcast Beat archive.", "Hi — I'm BB AI. Ask me anything about broadcast, production, or post technology. I'll cite sources from the AV Beat archive.",
}, },
]); ]);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -71,7 +71,7 @@ export default function AskBBAI() {
aria-label="Ask BB AI" aria-label="Ask BB AI"
> >
<span className="relative inline-block w-3 h-3"> <span className="relative inline-block w-3 h-3">
<span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#60a5fa)]" /> <span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#E67E22)]" />
<span className="bb-ring" /> <span className="bb-ring" />
</span> </span>
<span className="font-serif">Ask BB AI</span> <span className="font-serif">Ask BB AI</span>
@@ -88,7 +88,7 @@ export default function AskBBAI() {
<header className="flex items-center justify-between px-5 py-4 border-b border-[#252525]"> <header className="flex items-center justify-between px-5 py-4 border-b border-[#252525]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="relative inline-block w-3 h-3"> <span className="relative inline-block w-3 h-3">
<span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#60a5fa)]" /> <span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#E67E22)]" />
<span className="bb-ring" /> <span className="bb-ring" />
</span> </span>
<h2 className="font-serif text-lg font-semibold text-white">BB AI</h2> <h2 className="font-serif text-lg font-semibold text-white">BB AI</h2>
@@ -109,7 +109,7 @@ export default function AskBBAI() {
className={ className={
"inline-block max-w-[85%] rounded px-3 py-2 text-sm whitespace-pre-wrap " + "inline-block max-w-[85%] rounded px-3 py-2 text-sm whitespace-pre-wrap " +
(m.role === "user" (m.role === "user"
? "bg-[var(--color-text-info,#60a5fa)] text-black" ? "bg-[var(--color-text-info,#E67E22)] text-black"
: "bg-[#111] border border-[#252525] text-[#e5e7eb]") : "bg-[#111] border border-[#252525] text-[#e5e7eb]")
} }
dangerouslySetInnerHTML={{ __html: m.role === "assistant" ? linkifyMarkdown(m.content) : escapeHtml(m.content) }} dangerouslySetInnerHTML={{ __html: m.role === "assistant" ? linkifyMarkdown(m.content) : escapeHtml(m.content) }}
@@ -133,18 +133,18 @@ export default function AskBBAI() {
onKeyDown={(e) => e.key === "Enter" && send()} onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Ask about an event, a vendor, a workflow…" placeholder="Ask about an event, a vendor, a workflow…"
disabled={busy} disabled={busy}
className="flex-1 bg-[#111] border border-[#252525] rounded px-3 py-2 text-sm text-[#e5e7eb] focus:border-[var(--color-text-info,#60a5fa)] focus:outline-none" className="flex-1 bg-[#111] border border-[#252525] rounded px-3 py-2 text-sm text-[#e5e7eb] focus:border-[var(--color-text-info,#E67E22)] focus:outline-none"
/> />
<button <button
onClick={send} onClick={send}
disabled={busy || !input.trim()} disabled={busy || !input.trim()}
className="bg-[var(--color-text-info,#60a5fa)] text-black font-semibold px-4 py-2 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed" className="bg-[var(--color-text-info,#E67E22)] text-black font-semibold px-4 py-2 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
> >
Send Send
</button> </button>
</div> </div>
<p className="text-[10px] font-mono text-[#6b7280] mt-2"> <p className="text-[10px] font-mono text-[#6b7280] mt-2">
30 messages/hour · cites Broadcast Beat archive 30 messages/hour · cites AV Beat archive
</p> </p>
</div> </div>
</div> </div>
@@ -161,7 +161,7 @@ function linkifyMarkdown(s: string): string {
let out = escapeHtml(s); let out = escapeHtml(s);
out = out.replace( out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g, /\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" class="text-[#60a5fa] hover:underline">$1</a>' '<a href="$2" class="text-[#E67E22] hover:underline">$1</a>'
); );
out = out.replace(/\n/g, "<br/>"); out = out.replace(/\n/g, "<br/>");
return out; return out;

View File

@@ -57,7 +57,7 @@ export default async function CompaniesInThisStory({ slugs }: { slugs: string[]
return ( return (
<div className="bg-[#111] border border-[#222] p-5"> <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]"> <h3 className="font-body font-bold text-xs text-[#E67E22] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
Companies in this story Companies in this story
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
@@ -83,7 +83,7 @@ export default async function CompaniesInThisStory({ slugs }: { slugs: string[]
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<span className="font-heading text-[#e0e0e0] text-sm font-bold group-hover:text-[#3b82f6] transition-colors truncate"> <span className="font-heading text-[#e0e0e0] text-sm font-bold group-hover:text-[#E67E22] transition-colors truncate">
{t.name} {t.name}
</span> </span>
</div> </div>

View File

@@ -112,12 +112,12 @@ export default function CompanyMentionsHover() {
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img src={preview.logoUrl} alt="" className="w-12 h-12 rounded bg-white p-1 object-contain flex-shrink-0" loading="lazy" /> <img src={preview.logoUrl} alt="" className="w-12 h-12 rounded bg-white p-1 object-contain flex-shrink-0" loading="lazy" />
) : ( ) : (
<div className="w-12 h-12 rounded bg-[#1a2535] flex items-center justify-center text-[#3b82f6] font-bold flex-shrink-0"> <div className="w-12 h-12 rounded bg-[#1a2535] flex items-center justify-center text-[#E67E22] font-bold flex-shrink-0">
{(preview.name || "?").trim().charAt(0).toUpperCase()} {(preview.name || "?").trim().charAt(0).toUpperCase()}
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link href={`/manufacturers/${preview.slug}`} className="font-semibold text-white hover:text-[#3b82f6] block truncate"> <Link href={`/manufacturers/${preview.slug}`} className="font-semibold text-white hover:text-[#E67E22] block truncate">
{preview.name} {preview.name}
</Link> </Link>
<div className="flex items-center gap-1.5 mt-1 flex-wrap text-[10px]"> <div className="flex items-center gap-1.5 mt-1 flex-wrap text-[10px]">
@@ -145,7 +145,7 @@ export default function CompanyMentionsHover() {
<ul className="space-y-1"> <ul className="space-y-1">
{preview.stories.slice(0, 3).map((s) => ( {preview.stories.slice(0, 3).map((s) => (
<li key={s.slug}> <li key={s.slug}>
<Link href={`/news/${s.slug}`} className="text-xs text-[#60a5fa] hover:text-[#93c5fd] hover:underline line-clamp-2"> <Link href={`/news/${s.slug}`} className="text-xs text-[#E67E22] hover:text-[#93c5fd] hover:underline line-clamp-2">
{s.title} {s.title}
</Link> </Link>
{s.date && <span className="text-[10px] text-[#666] ml-1">· {new Date(s.date).toISOString().slice(0, 10)}</span>} {s.date && <span className="text-[10px] text-[#666] ml-1">· {new Date(s.date).toISOString().slice(0, 10)}</span>}
@@ -156,11 +156,11 @@ export default function CompanyMentionsHover() {
)} )}
<div className="border-t border-[#2a3a50] mt-3 pt-2 flex items-center justify-between"> <div className="border-t border-[#2a3a50] mt-3 pt-2 flex items-center justify-between">
<Link href={`/manufacturers/${preview.slug}`} className="text-xs font-semibold text-[#60a5fa] hover:text-[#93c5fd] hover:underline"> <Link href={`/manufacturers/${preview.slug}`} className="text-xs font-semibold text-[#E67E22] hover:text-[#93c5fd] hover:underline">
Full profile Full profile
</Link> </Link>
{preview.website && ( {preview.website && (
<a href={preview.website} target="_blank" rel="noopener noreferrer" className="text-[10px] text-[#888] hover:text-[#3b82f6]"> <a href={preview.website} target="_blank" rel="noopener noreferrer" className="text-[10px] text-[#888] hover:text-[#E67E22]">
{preview.website.replace(/^https?:\/\//, "").replace(/\/$/, "")} {preview.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}
</a> </a>
)} )}

View File

@@ -72,11 +72,11 @@ export default function CookieConsent() {
id="cookie-consent-desc" id="cookie-consent-desc"
className="text-[#aaa] text-sm font-body leading-relaxed" className="text-[#aaa] text-sm font-body leading-relaxed"
> >
<span className="font-bold text-white">Broadcast Beat</span> uses cookies and similar technologies to improve your experience, analyze site traffic, and serve relevant advertising. By clicking{" "} <span className="font-bold text-white">AV Beat</span> uses cookies and similar technologies to improve your experience, analyze site traffic, and serve relevant advertising. By clicking{" "}
<strong className="text-white">Accept All</strong>, you consent to our use of cookies.{" "} <strong className="text-white">Accept All</strong>, you consent to our use of cookies.{" "}
<Link <Link
href="/privacy" href="/privacy"
className="text-[#3b82f6] underline hover:text-blue-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors" className="text-[#E67E22] underline hover:text-blue-400 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors"
> >
Privacy Policy Privacy Policy
</Link> </Link>
@@ -86,7 +86,7 @@ export default function CookieConsent() {
<button <button
type="button" type="button"
onClick={handleDecline} onClick={handleDecline}
className="px-4 py-2 text-sm font-body font-bold text-[#888] border border-[#333] rounded hover:border-[#555] hover:text-[#aaa] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0d1117]" className="px-4 py-2 text-sm font-body font-bold text-[#888] border border-[#333] rounded hover:border-[#555] hover:text-[#aaa] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] focus-visible:ring-offset-1 focus-visible:ring-offset-[#0d1117]"
aria-label="Decline non-essential cookies" aria-label="Decline non-essential cookies"
> >
Decline Decline
@@ -95,7 +95,7 @@ export default function CookieConsent() {
ref={acceptRef} ref={acceptRef}
type="button" type="button"
onClick={handleAccept} onClick={handleAccept}
className="px-5 py-2 text-sm font-body font-bold text-white bg-[#3b82f6] rounded hover:bg-blue-500 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0d1117]" className="px-5 py-2 text-sm font-body font-bold text-white bg-[#E67E22] rounded hover:bg-blue-500 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0d1117]"
aria-label="Accept all cookies" aria-label="Accept all cookies"
> >
Accept All Accept All

View File

@@ -99,7 +99,7 @@ export default function EventsDropdown() {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-haspopup="true" aria-haspopup="true"
aria-expanded={open} aria-expanded={open}
className="bb-browse-item gap-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]" className="bb-browse-item gap-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]"
> >
<span>Show coverage</span> <span>Show coverage</span>
<span aria-hidden="true" className="text-[10px]"></span> <span aria-hidden="true" className="text-[10px]"></span>
@@ -113,12 +113,12 @@ export default function EventsDropdown() {
style={{ borderRadius: "var(--border-radius-md,6px)" }} style={{ borderRadius: "var(--border-radius-md,6px)" }}
> >
<div className="relative p-4"> <div className="relative p-4">
<span aria-hidden className="absolute top-2 left-2 w-3 h-3 border-t border-l border-[var(--color-text-info,#60a5fa)]"></span> <span aria-hidden className="absolute top-2 left-2 w-3 h-3 border-t border-l border-[var(--color-text-info,#E67E22)]"></span>
<span aria-hidden className="absolute bottom-2 right-2 w-3 h-3 border-b border-r border-[var(--color-text-info,#60a5fa)]"></span> <span aria-hidden className="absolute bottom-2 right-2 w-3 h-3 border-b border-r border-[var(--color-text-info,#E67E22)]"></span>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<h3 className="font-serif text-base font-semibold">Show coverage</h3> <h3 className="font-serif text-base font-semibold">Show coverage</h3>
<span className="inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]"> <span className="inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#E67E22)]">
<span className="bb-pulse-dot" /> <span className="bb-pulse-dot" />
BB AI BB AI
</span> </span>
@@ -158,7 +158,7 @@ export default function EventsDropdown() {
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" /> <polyline points="12 6 12 12 16 14" />
</svg> </svg>
<span className="text-[var(--color-text-info,#60a5fa)]">T-{e.days_until}d</span> <span className="text-[var(--color-text-info,#E67E22)]">T-{e.days_until}d</span>
</> </>
)} )}
</div> </div>
@@ -168,7 +168,7 @@ export default function EventsDropdown() {
</div> </div>
<div className="flex items-center gap-2 text-[10px] font-mono text-[#6b7280] mt-1"> <div className="flex items-center gap-2 text-[10px] font-mono text-[#6b7280] mt-1">
<span>{fmtDateRange(e.start_date, e.end_date)}</span> <span>{fmtDateRange(e.start_date, e.end_date)}</span>
{host && <span className="text-[var(--color-text-info,#60a5fa)]">· {host}</span>} {host && <span className="text-[var(--color-text-info,#E67E22)]">· {host}</span>}
</div> </div>
</Link> </Link>
</li> </li>
@@ -177,7 +177,7 @@ export default function EventsDropdown() {
</ul> </ul>
<div className="mt-3 pt-2 border-t border-[#252525] flex items-center justify-between text-[10px] font-mono text-[#6b7280]"> <div className="mt-3 pt-2 border-t border-[#252525] flex items-center justify-between text-[10px] font-mono text-[#6b7280]">
<Link href="/show-coverage" className="text-[var(--color-text-info,#60a5fa)] hover:underline" onClick={() => setOpen(false)}> <Link href="/show-coverage" className="text-[var(--color-text-info,#E67E22)] hover:underline" onClick={() => setOpen(false)}>
See all 13 events See all 13 events
</Link> </Link>
{loadedAt && <span>auto-updated · {ago(loadedAt)}</span>} {loadedAt && <span>auto-updated · {ago(loadedAt)}</span>}

View File

@@ -73,7 +73,7 @@ export default async function FeaturedBento() {
<div className="flex items-center gap-3 mb-4 md:mb-5 flex-wrap"> <div className="flex items-center gap-3 mb-4 md:mb-5 flex-wrap">
<span className="section-label">Staff Editorial</span> <span className="section-label">Staff Editorial</span>
<div className="flex-1 h-px bg-[#2a2a2a]" /> <div className="flex-1 h-px bg-[#2a2a2a]" />
<Link href="/news/featured" className="text-[11px] font-mono text-[var(--color-text-info,#60a5fa)] hover:underline whitespace-nowrap"> <Link href="/news/featured" className="text-[11px] font-mono text-[var(--color-text-info,#E67E22)] hover:underline whitespace-nowrap">
All featured All featured
</Link> </Link>
</div> </div>
@@ -89,7 +89,7 @@ export default async function FeaturedBento() {
{/* Cinematic 21:9 hero — fills left column, aspect ratio preserved */} {/* Cinematic 21:9 hero — fills left column, aspect ratio preserved */}
<Link <Link
href={`/news/${hero.wp_slug}`} href={`/news/${hero.wp_slug}`}
className="group block relative overflow-hidden rounded border border-[#252525] bg-[#0b0f17] hover:border-[var(--color-text-info,#60a5fa)] transition-colors" className="group block relative overflow-hidden rounded border border-[#252525] bg-[#0b0f17] hover:border-[var(--color-text-info,#E67E22)] transition-colors"
> >
<div className="relative aspect-[21/9] md:aspect-[21/8] bg-[#111]"> <div className="relative aspect-[21/9] md:aspect-[21/8] bg-[#111]">
<img <img
@@ -99,7 +99,7 @@ export default async function FeaturedBento() {
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/65 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black via-black/65 to-transparent" />
<span className="absolute top-4 left-4 inline-flex items-center gap-1.5 bg-[var(--color-text-info,#60a5fa)] text-black px-3 py-1 rounded font-mono text-[11px] uppercase tracking-widest font-bold shadow-md"> <span className="absolute top-4 left-4 inline-flex items-center gap-1.5 bg-[var(--color-text-info,#E67E22)] text-black px-3 py-1 rounded font-mono text-[11px] uppercase tracking-widest font-bold shadow-md">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l2.6 7.6H22l-6.2 4.5L18.4 22 12 17.3 5.6 22l2.6-7.9L2 9.6h7.4z" /> <path d="M12 2l2.6 7.6H22l-6.2 4.5L18.4 22 12 17.3 5.6 22l2.6-7.9L2 9.6h7.4z" />
</svg> </svg>
@@ -108,11 +108,11 @@ export default async function FeaturedBento() {
<div className="absolute bottom-0 left-0 right-0 p-6 md:p-10"> <div className="absolute bottom-0 left-0 right-0 p-6 md:p-10">
{hero.category && ( {hero.category && (
<div className="text-[10px] font-mono uppercase tracking-widest text-[var(--color-text-info,#60a5fa)] mb-2"> <div className="text-[10px] font-mono uppercase tracking-widest text-[var(--color-text-info,#E67E22)] mb-2">
{hero.category} {hero.category}
</div> </div>
)} )}
<h3 className="font-serif text-2xl md:text-4xl lg:text-5xl text-white leading-tight max-w-3xl group-hover:text-[var(--color-text-info,#60a5fa)] transition-colors"> <h3 className="font-serif text-2xl md:text-4xl lg:text-5xl text-white leading-tight max-w-3xl group-hover:text-[var(--color-text-info,#E67E22)] transition-colors">
{hero.title} {hero.title}
</h3> </h3>
{hero.excerpt && ( {hero.excerpt && (
@@ -121,9 +121,9 @@ export default async function FeaturedBento() {
</p> </p>
)} )}
<div className="mt-4 flex flex-wrap items-center gap-3 text-[11px] font-mono text-[#9ca3af]"> <div className="mt-4 flex flex-wrap items-center gap-3 text-[11px] font-mono text-[#9ca3af]">
<span>{hero.author_name || "Broadcast Beat"}</span> <span>{hero.author_name || "AV Beat"}</span>
<StarRating seed={hero.wp_slug} publishedAt={hero.wp_published_at} size="sm" /> <StarRating seed={hero.wp_slug} publishedAt={hero.wp_published_at} size="sm" />
<span aria-hidden="true" className="text-[var(--color-text-info,#60a5fa)] ml-2"> Read full story</span> <span aria-hidden="true" className="text-[var(--color-text-info,#E67E22)] ml-2"> Read full story</span>
</div> </div>
</div> </div>
</div> </div>
@@ -140,7 +140,7 @@ export default async function FeaturedBento() {
<Link <Link
key={r.wp_slug} key={r.wp_slug}
href={`/news/${r.wp_slug}`} href={`/news/${r.wp_slug}`}
className="group flex flex-col h-full rounded border border-[#252525] bg-[#0b0f17] overflow-hidden hover:border-[var(--color-text-info,#60a5fa)] transition-colors" className="group flex flex-col h-full rounded border border-[#252525] bg-[#0b0f17] overflow-hidden hover:border-[var(--color-text-info,#E67E22)] transition-colors"
> >
<div className="aspect-[16/10] bg-[#111] relative flex-shrink-0"> <div className="aspect-[16/10] bg-[#111] relative flex-shrink-0">
<img <img
@@ -150,10 +150,10 @@ export default async function FeaturedBento() {
/> />
</div> </div>
<div className="p-3 flex flex-col gap-1 flex-1 min-h-0 overflow-hidden"> <div className="p-3 flex flex-col gap-1 flex-1 min-h-0 overflow-hidden">
<div className="text-[9px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]"> <div className="text-[9px] font-mono uppercase tracking-wider text-[var(--color-text-info,#E67E22)]">
{r.category || "News"} {r.category || "News"}
</div> </div>
<h4 className="font-serif text-sm leading-tight text-[#e5e7eb] group-hover:text-[var(--color-text-info,#60a5fa)] line-clamp-2"> <h4 className="font-serif text-sm leading-tight text-[#e5e7eb] group-hover:text-[var(--color-text-info,#E67E22)] line-clamp-2">
{r.title} {r.title}
</h4> </h4>
<div className="mt-auto"> <div className="mt-auto">

View File

@@ -54,7 +54,7 @@ export default async function FeaturedCarouselServer() {
for (const r of (ai.data || []) as any[]) { for (const r of (ai.data || []) as any[]) {
const persona = Array.isArray(r.persona) ? r.persona[0] : r.persona; const persona = Array.isArray(r.persona) ? r.persona[0] : r.persona;
const author = persona?.name || "Broadcast Beat"; const author = persona?.name || "AV Beat";
slides.push({ slides.push({
id: r.id, id: r.id,
slug: r.slug, slug: r.slug,
@@ -74,7 +74,7 @@ export default async function FeaturedCarouselServer() {
for (const r of (wp.data || []) as any[]) { for (const r of (wp.data || []) as any[]) {
const image = r.featured_image ? rewriteLegacyImageUrl(r.featured_image) : null; const image = r.featured_image ? rewriteLegacyImageUrl(r.featured_image) : null;
const author = r.author_name || "Broadcast Beat"; const author = r.author_name || "AV Beat";
slides.push({ slides.push({
id: String(r.wp_id), id: String(r.wp_id),
slug: r.wp_slug, slug: r.wp_slug,

View File

@@ -15,7 +15,7 @@ const footerColumns = [
{ {
heading: "About", heading: "About",
links: [ links: [
{ label: "About Broadcast Beat", href: "/about" }, { label: "About AV Beat", href: "/about" },
{ label: "Our Mission", href: "/about#mission" }, { label: "Our Mission", href: "/about#mission" },
{ label: "Editorial Team", href: "/about#team" }, { label: "Editorial Team", href: "/about#team" },
{ label: "Advertise With Us", href: "/advertise" }, { label: "Advertise With Us", href: "/advertise" },
@@ -50,11 +50,11 @@ const footerColumns = [
{ {
heading: "Follow Us", heading: "Follow Us",
links: [ links: [
{ label: "LinkedIn", href: "https://linkedin.com/company/broadcastbeat" }, { label: "LinkedIn", href: "https://linkedin.com/company/avbeat" },
{ label: "Twitter / X", href: "https://twitter.com/broadcastbeat" }, { label: "Twitter / X", href: "https://twitter.com/avbeat" },
{ label: "Facebook", href: "https://facebook.com/broadcastbeat" }, { label: "Facebook", href: "https://facebook.com/avbeat" },
{ label: "Instagram", href: "https://instagram.com/broadcastbeat" }, { label: "Instagram", href: "https://instagram.com/avbeat" },
{ label: "YouTube", href: "https://youtube.com/@broadcastbeat" }, { label: "YouTube", href: "https://youtube.com/@avbeat" },
{ label: "RSS Feed", href: "/rss" }] { label: "RSS Feed", href: "/rss" }]
}]; }];
@@ -75,7 +75,7 @@ export default function Footer() {
<div className="border-t border-[#222] pt-8 pb-6 grid grid-cols-2 md:grid-cols-4 gap-8"> <div className="border-t border-[#222] pt-8 pb-6 grid grid-cols-2 md:grid-cols-4 gap-8">
{footerColumns?.map((col) => {footerColumns?.map((col) =>
<div key={col?.heading}> <div key={col?.heading}>
<h4 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#3b82f6]/20"> <h4 className="font-body font-bold text-xs text-[#E67E22] uppercase tracking-widest mb-4 pb-2 border-b border-[#E67E22]/20">
{col?.heading} {col?.heading}
</h4> </h4>
<ul className="space-y-2"> <ul className="space-y-2">
@@ -86,13 +86,13 @@ export default function Footer() {
href={link?.href} href={link?.href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="footer-link font-body text-sm text-[#777777] hover:text-[#3b82f6] transition-colors leading-relaxed inline-block"> className="footer-link font-body text-sm text-[#777777] hover:text-[#E67E22] transition-colors leading-relaxed inline-block">
{link?.label} {link?.label}
</a> : </a> :
<Link <Link
href={link?.href} href={link?.href}
className="footer-link font-body text-sm text-[#777777] hover:text-[#3b82f6] transition-colors leading-relaxed inline-block"> className="footer-link font-body text-sm text-[#777777] hover:text-[#E67E22] transition-colors leading-relaxed inline-block">
{link?.label} {link?.label}
</Link> </Link>
} }
@@ -108,44 +108,44 @@ export default function Footer() {
<div className="border-t border-[#222] pt-5 pb-3 flex flex-col sm:flex-row items-center justify-between gap-4"> <div className="border-t border-[#222] pt-5 pb-3 flex flex-col sm:flex-row items-center justify-between gap-4">
<Link <Link
href="/home-page" href="/home-page"
aria-label="Broadcast Beat — home" aria-label="AV Beat — home"
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"> className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity">
<AppImage <AppImage
src="/assets/images/logo.png" src="/assets/images/logo.png"
alt="Broadcast Beat" alt="AV Beat"
width={180} width={180}
height={24} height={42}
className="h-6 w-auto object-contain" /> className="h-9 w-auto object-contain" />
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<a href="https://linkedin.com/company/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on LinkedIn" <a href="https://linkedin.com/company/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on LinkedIn"
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon"> className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
<LinkedInIcon size={17} /> <LinkedInIcon size={17} />
</a> </a>
<a href="https://instagram.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Instagram" <a href="https://instagram.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Instagram"
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon"> className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
<InstagramIcon size={17} /> <InstagramIcon size={17} />
</a> </a>
<a href="https://twitter.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Twitter/X" <a href="https://twitter.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Twitter/X"
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon"> className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
<TwitterXIcon size={17} /> <TwitterXIcon size={17} />
</a> </a>
<a href="https://facebook.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Facebook" <a href="https://facebook.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Facebook"
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon"> className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
<FacebookIcon size={17} /> <FacebookIcon size={17} />
</a> </a>
</div> </div>
<div className="flex items-center gap-4 text-xs font-body text-[#555]"> <div className="flex items-center gap-4 text-xs font-body text-[#555]">
<Link href="/privacy" className="hover:text-[#3b82f6] transition-colors">Privacy Policy</Link> <Link href="/privacy" className="hover:text-[#E67E22] transition-colors">Privacy Policy</Link>
<Link href="/terms" className="hover:text-[#3b82f6] transition-colors">Terms of Service</Link> <Link href="/terms" className="hover:text-[#E67E22] transition-colors">Terms of Service</Link>
<span> <span>
© 2026 <a © 2026 <a
href="https://www.relevantmediaproperties.com/" href="https://www.relevantmediaproperties.com/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-[#3b82f6] transition-colors underline-offset-2 hover:underline"> className="hover:text-[#E67E22] transition-colors underline-offset-2 hover:underline">
Relevant Media Properties Relevant Media Properties
</a>. All rights reserved. </a>. All rights reserved.
</span> </span>

View File

@@ -261,14 +261,14 @@ export default function Header() {
<div className="max-w-container mx-auto px-4"> <div className="max-w-container mx-auto px-4">
<div className="flex items-center justify-between h-[96px] gap-4"> <div className="flex items-center justify-between h-[96px] gap-4">
{/* Logo */} {/* Logo */}
<Link href="/home-page" className="flex-shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]"> <Link href="/home-page" className="flex-shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]">
<AppImage <AppImage
src="/assets/images/logo.png" src="/assets/images/logo.png"
alt="Broadcast Beat logo" alt="AV Beat logo"
width={200} width={200}
height={26} height={47}
priority={true} priority={true}
className="h-7 w-auto object-contain" className="h-10 w-auto object-contain"
/> />
</Link> </Link>
@@ -283,7 +283,7 @@ export default function Header() {
<Link <Link
href="/reading-list" href="/reading-list"
aria-label="Reading List" aria-label="Reading List"
className="flex items-center text-[#888] hover:text-[#3b82f6] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]"> className="flex items-center text-[#888] hover:text-[#E67E22] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg> </svg>
@@ -296,7 +296,7 @@ export default function Header() {
{/* Mobile Hamburger */} {/* Mobile Hamburger */}
<button <button
ref={hamburgerRef} ref={hamburgerRef}
className="lg:hidden p-2 text-[#cccccc] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]" className="lg:hidden p-2 text-[#cccccc] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#E67E22]"
onClick={() => setMobileOpen(!mobileOpen)} onClick={() => setMobileOpen(!mobileOpen)}
aria-label={mobileOpen ? "Close menu" : "Open menu"} aria-label={mobileOpen ? "Close menu" : "Open menu"}
aria-expanded={mobileOpen} aria-expanded={mobileOpen}
@@ -319,7 +319,7 @@ export default function Header() {
key={link?.label} key={link?.label}
href={link?.href} href={link?.href}
ref={(el) => { navLinksRef.current[i] = el; }} ref={(el) => { navLinksRef.current[i] = el; }}
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-accent hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6]" className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-accent hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22]"
onClick={() => setMobileOpen(false)}> onClick={() => setMobileOpen(false)}>
{link?.label} {link?.label}
</Link> </Link>
@@ -327,7 +327,7 @@ export default function Header() {
{/* Reading List mobile link */} {/* Reading List mobile link */}
<Link <Link
href="/reading-list" href="/reading-list"
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-accent hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] flex items-center gap-2" className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-accent hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22] flex items-center gap-2"
onClick={() => setMobileOpen(false)}> onClick={() => setMobileOpen(false)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" /> <path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
@@ -338,7 +338,7 @@ export default function Header() {
{currentUserId && ( {currentUserId && (
<Link <Link
href="/account" href="/account"
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-[#3b82f6] hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] flex items-center gap-2" className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-[#E67E22] hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22] flex items-center gap-2"
onClick={() => setMobileOpen(false)}> onClick={() => setMobileOpen(false)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
@@ -350,7 +350,7 @@ export default function Header() {
{isAdmin && ( {isAdmin && (
<Link <Link
href="/admin/import" href="/admin/import"
className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-[#3b82f6] hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] flex items-center gap-2" className="px-2 py-2.5 text-sm font-body font-bold uppercase tracking-wide text-[#cccccc] hover:text-[#E67E22] hover:bg-[#1a1a1a] border-b border-[#222] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22] flex items-center gap-2"
onClick={() => setMobileOpen(false)}> onClick={() => setMobileOpen(false)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
@@ -364,14 +364,14 @@ export default function Header() {
.filter((n) => n.dropdown && n.dropdown.length > 0) .filter((n) => n.dropdown && n.dropdown.length > 0)
.map((section) => ( .map((section) => (
<div key={section.label} className="border-b border-[#222]"> <div key={section.label} className="border-b border-[#222]">
<div className="px-2 py-2 text-[10px] font-body font-bold text-[#3b82f6] uppercase tracking-widest bg-[#0d0d0d]"> <div className="px-2 py-2 text-[10px] font-body font-bold text-[#E67E22] uppercase tracking-widest bg-[#0d0d0d]">
{section.label} {section.label}
</div> </div>
{section.dropdown!.map((link) => ( {section.dropdown!.map((link) => (
<Link <Link
key={link.href + link.label} key={link.href + link.label}
href={link.href} href={link.href}
className="px-4 py-2 text-xs font-body text-[#888] hover:text-accent hover:bg-[#1a1a1a] block transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6]" className="px-4 py-2 text-xs font-body text-[#888] hover:text-accent hover:bg-[#1a1a1a] block transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22]"
onClick={() => setMobileOpen(false)}> onClick={() => setMobileOpen(false)}>
{link.label} {link.label}
</Link> </Link>
@@ -399,7 +399,7 @@ export default function Header() {
type="button" type="button"
onClick={submitSearch} onClick={submitSearch}
aria-label="Submit search" aria-label="Submit search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#666] hover:text-[#3b82f6] transition-colors cursor-pointer" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#666] hover:text-[#E67E22] transition-colors cursor-pointer"
> >
<SearchIcon size={13} strokeWidth={1.75} /> <SearchIcon size={13} strokeWidth={1.75} />
</button> </button>
@@ -418,22 +418,22 @@ export default function Header() {
<div className="max-w-container mx-auto px-4"> <div className="max-w-container mx-auto px-4">
<div className="bg-[#1a2535] border-b border-[#2a3a50] h-8 flex items-center justify-between px-4"> <div className="bg-[#1a2535] border-b border-[#2a3a50] h-8 flex items-center justify-between px-4">
<p className="text-[#999999] text-xs font-body hidden sm:block"> <p className="text-[#999999] text-xs font-body hidden sm:block">
News & Intelligence for Broadcast, Post-Production & Streaming Technology Where AV Meets IT Pro AV, Broadcast, Streaming & Post-Production
</p> </p>
<div className="flex items-center gap-3 ml-auto"> <div className="flex items-center gap-3 ml-auto">
<a href="https://linkedin.com/company/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on LinkedIn" <a href="https://linkedin.com/company/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on LinkedIn"
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon"> className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors social-icon">
<LinkedInIcon size={14} /> <LinkedInIcon size={14} />
</a> </a>
<a href="https://instagram.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Instagram" <a href="https://instagram.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Instagram"
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon"> className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors social-icon">
<InstagramIcon size={14} /> <InstagramIcon size={14} />
</a> </a>
<a href="https://facebook.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Facebook" <a href="https://facebook.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Facebook"
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon"> className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors social-icon">
<FacebookIcon size={14} /> <FacebookIcon size={14} />
</a> </a>
<a href="/rss" aria-label="Broadcast Beat RSS Feed" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors social-icon"> <a href="/rss" aria-label="AV Beat RSS Feed" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors social-icon">
<RssIcon size={14} /> <RssIcon size={14} />
</a> </a>
<div className="w-px h-3.5 bg-[#333] mx-1" /> <div className="w-px h-3.5 bg-[#333] mx-1" />
@@ -442,7 +442,7 @@ export default function Header() {
<div className="w-px h-3.5 bg-[#333] mx-1" /> <div className="w-px h-3.5 bg-[#333] mx-1" />
{currentUserId ? ( {currentUserId ? (
<> <>
<Link href="/account" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/account" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
My Profile My Profile
</Link> </Link>
<button <button
@@ -452,33 +452,33 @@ export default function Header() {
await supabase.auth.signOut(); await supabase.auth.signOut();
window.location.href = '/forum'; window.location.href = '/forum';
}} }}
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider"
> >
Log Out Log Out
</button> </button>
</> </>
) : ( ) : (
<> <>
<Link href="/forum/login" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/forum/login" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
Sign In Sign In
</Link> </Link>
<Link href="/forum/register" className="text-[#3b82f6] hover:text-blue-300 focus:text-blue-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/forum/register" className="text-[#E67E22] hover:text-blue-300 focus:text-blue-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
Join Join
</Link> </Link>
</> </>
)} )}
{isAdmin && ( {isAdmin && (
<> <>
<Link href="/admin/import" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/admin/import" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
WP Import WP Import
</Link> </Link>
<Link href="/admin/articles" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/admin/articles" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
Articles Articles
</Link> </Link>
<Link href="/dashboard/editorial" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/dashboard/editorial" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
Editorial Editorial
</Link> </Link>
<Link href="/admin/newsletter" className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/admin/newsletter" className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
Newsletter Newsletter
</Link> </Link>
@@ -487,7 +487,7 @@ export default function Header() {
<button <button
type="button" type="button"
aria-haspopup="true" aria-haspopup="true"
className="text-[#999] hover:text-[#3b82f6] focus:text-[#3b82f6] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider inline-flex items-center gap-1"> className="text-[#999] hover:text-[#E67E22] focus:text-[#E67E22] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider inline-flex items-center gap-1">
Tools Tools
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true"> <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9" /> <polyline points="6 9 12 15 18 9" />
@@ -497,25 +497,25 @@ export default function Header() {
role="menu" role="menu"
aria-label="Admin tools" aria-label="Admin tools"
className="absolute right-0 top-full min-w-[200px] bg-[#0d0d0d] border border-[#252525] shadow-xl rounded-sm py-1 opacity-0 invisible translate-y-1 group-hover/tools:opacity-100 group-hover/tools:visible group-hover/tools:translate-y-0 group-focus-within/tools:opacity-100 group-focus-within/tools:visible group-focus-within/tools:translate-y-0 transition-all duration-150 z-50"> className="absolute right-0 top-full min-w-[200px] bg-[#0d0d0d] border border-[#252525] shadow-xl rounded-sm py-1 opacity-0 invisible translate-y-1 group-hover/tools:opacity-100 group-hover/tools:visible group-hover/tools:translate-y-0 group-focus-within/tools:opacity-100 group-focus-within/tools:visible group-focus-within/tools:translate-y-0 transition-all duration-150 z-50">
<Link href="/admin/users" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Users</Link> <Link href="/admin/users" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Users</Link>
<Link href="/admin/analytics" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Analytics</Link> <Link href="/admin/analytics" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Analytics</Link>
<Link href="/admin/audit-log" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Audit Log</Link> <Link href="/admin/audit-log" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Audit Log</Link>
<Link href="/admin/notifications" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Notifications</Link> <Link href="/admin/notifications" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Notifications</Link>
<Link href="/admin/forum-seed" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Forum Seed</Link> <Link href="/admin/forum-seed" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Forum Seed</Link>
<Link href="/admin/company-tracker" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Company Tracker</Link> <Link href="/admin/company-tracker" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Company Tracker</Link>
<Link href="/dashboard/show-calendar" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Show Calendar</Link> <Link href="/dashboard/show-calendar" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Show Calendar</Link>
<Link href="/dashboard/ai-console" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#3b82f6] hover:bg-[#1a1a1a] hover:text-blue-300 focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-blue-300 uppercase tracking-wider transition-colors">AI Console</Link> <Link href="/dashboard/ai-console" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#E67E22] hover:bg-[#1a1a1a] hover:text-blue-300 focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-blue-300 uppercase tracking-wider transition-colors">AI Console</Link>
<Link href="/admin/banned-terms" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#3b82f6] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#3b82f6] uppercase tracking-wider transition-colors">Banned Terms</Link> <Link href="/admin/banned-terms" role="menuitem" className="block px-4 py-2 text-xs font-body text-[#bbb] hover:bg-[#1a1a1a] hover:text-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Banned Terms</Link>
</div> </div>
</div> </div>
<Link href="/admin" className="text-[#3b82f6] hover:text-blue-300 focus:text-blue-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] transition-colors text-xs font-body font-bold uppercase tracking-wider"> <Link href="/admin" className="text-[#E67E22] hover:text-blue-300 focus:text-blue-300 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] transition-colors text-xs font-body font-bold uppercase tracking-wider">
Dashboard Dashboard
</Link> </Link>
</> </>
)} )}
{/* Inline Email Subscription Form */} {/* Inline Email Subscription Form */}
{subStatus === "success" ? ( {subStatus === "success" ? (
<span className="text-[#3b82f6] text-xs font-body font-bold whitespace-nowrap">{subMessage}</span> <span className="text-[#E67E22] text-xs font-body font-bold whitespace-nowrap">{subMessage}</span>
) : ( ) : (
<form onSubmit={handleSubscribe} className="flex items-center gap-1" aria-label="Newsletter subscription"> <form onSubmit={handleSubscribe} className="flex items-center gap-1" aria-label="Newsletter subscription">
<input <input
@@ -525,12 +525,12 @@ export default function Header() {
placeholder="your@email.com" placeholder="your@email.com"
aria-label="Email address for newsletter" aria-label="Email address for newsletter"
disabled={subStatus === "loading"} disabled={subStatus === "loading"}
className="h-5 px-2 text-[11px] bg-[#0d1520] border border-[#2a3a50] text-[#ccc] placeholder-[#555] rounded focus:outline-none focus:border-[#3b82f6] focus-visible:ring-1 focus-visible:ring-[#3b82f6] w-32 disabled:opacity-50 transition-colors" className="h-5 px-2 text-[11px] bg-[#0d1520] border border-[#2a3a50] text-[#ccc] placeholder-[#555] rounded focus:outline-none focus:border-[#E67E22] focus-visible:ring-1 focus-visible:ring-[#E67E22] w-32 disabled:opacity-50 transition-colors"
/> />
<button <button
type="submit" type="submit"
disabled={subStatus === "loading"} disabled={subStatus === "loading"}
className="h-5 px-2.5 text-[11px] font-body font-bold uppercase tracking-wider bg-[#3b82f6] hover:bg-[#2563eb] text-white rounded disabled:opacity-50 transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-1 focus-visible:ring-white focus-visible:ring-offset-1 focus-visible:ring-offset-[#3b82f6]" className="h-5 px-2.5 text-[11px] font-body font-bold uppercase tracking-wider bg-[#E67E22] hover:bg-[#C8651B] text-white rounded disabled:opacity-50 transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-1 focus-visible:ring-white focus-visible:ring-offset-1 focus-visible:ring-offset-[#E67E22]"
> >
{subStatus === "loading" ? "..." : "Subscribe"} {subStatus === "loading" ? "..." : "Subscribe"}
</button> </button>

View File

@@ -156,7 +156,7 @@ export default function LanguageSwitcher({ currentLang, onLanguageChange, compac
<div ref={dropdownRef} className="relative z-[100]"> <div ref={dropdownRef} className="relative z-[100]">
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]" className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]"
aria-label={`Language: ${currentLangData.name}. Click to change.`} aria-label={`Language: ${currentLangData.name}. Click to change.`}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-haspopup="listbox" aria-haspopup="listbox"
@@ -184,14 +184,14 @@ export default function LanguageSwitcher({ currentLang, onLanguageChange, compac
onClick={() => handleSelect(lang.code)} onClick={() => handleSelect(lang.code)}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs transition-colors text-left ${ className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs transition-colors text-left ${
activeLang === lang.code activeLang === lang.code
? 'bg-[#3b82f6]/10 text-[#3b82f6]' ? 'bg-[#E67E22]/10 text-[#E67E22]'
: 'text-[#888] hover:text-white hover:bg-[#1a1a1a]' : 'text-[#888] hover:text-white hover:bg-[#1a1a1a]'
}`} }`}
> >
<span className="text-sm">{lang.flag}</span> <span className="text-sm">{lang.flag}</span>
<span>{lang.nativeName}</span> <span>{lang.nativeName}</span>
{activeLang === lang.code && ( {activeLang === lang.code && (
<svg className="w-3 h-3 ml-auto text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3 ml-auto text-[#E67E22]" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
)} )}

View File

@@ -193,7 +193,7 @@ export default function NotificationCenter() {
aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ""}`} aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ""}`}
aria-expanded={open} aria-expanded={open}
aria-haspopup="true" aria-haspopup="true"
className="relative flex items-center justify-center w-7 h-7 text-[#888] hover:text-[#3b82f6] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]" className="relative flex items-center justify-center w-7 h-7 text-[#888] hover:text-[#E67E22] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22] focus-visible:ring-offset-1 focus-visible:ring-offset-[#111]"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /> <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
@@ -202,7 +202,7 @@ export default function NotificationCenter() {
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
aria-hidden="true" aria-hidden="true"
className="absolute -top-1 -right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-[#3b82f6] text-white text-[9px] font-bold leading-none" className="absolute -top-1 -right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-[#E67E22] text-white text-[9px] font-bold leading-none"
> >
{unreadCount > 99 ? "99+" : unreadCount} {unreadCount > 99 ? "99+" : unreadCount}
</span> </span>
@@ -225,7 +225,7 @@ export default function NotificationCenter() {
{notifications.length > 0 && ( {notifications.length > 0 && (
<button <button
onClick={dismissAll} onClick={dismissAll}
className="text-[10px] text-[#666] hover:text-[#3b82f6] transition-colors uppercase tracking-wide font-bold focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]" className="text-[10px] text-[#666] hover:text-[#E67E22] transition-colors uppercase tracking-wide font-bold focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]"
> >
Clear all Clear all
</button> </button>
@@ -233,7 +233,7 @@ export default function NotificationCenter() {
<button <button
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
aria-label="Close notifications" aria-label="Close notifications"
className="text-[#555] hover:text-[#ccc] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]" className="text-[#555] hover:text-[#ccc] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#E67E22]"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
@@ -287,9 +287,9 @@ function FilterTabs({ notifications }: { notifications: Notification[] }) {
<button <button
key={tab.key} key={tab.key}
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
className={`flex-shrink-0 px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6] ${ className={`flex-shrink-0 px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22] ${
activeTab === tab.key activeTab === tab.key
? "text-[#3b82f6] border-b-2 border-[#3b82f6]" ? "text-[#E67E22] border-b-2 border-[#E67E22]"
: "text-[#666] hover:text-[#aaa]" : "text-[#666] hover:text-[#aaa]"
}`} }`}
> >
@@ -359,7 +359,7 @@ function NotificationItem({ notification: n }: { notification: Notification }) {
<button <button
onClick={handleDismiss} onClick={handleDismiss}
aria-label="Dismiss notification" aria-label="Dismiss notification"
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-[#444] hover:text-[#888] transition-all focus:outline-none focus:opacity-100 focus-visible:ring-1 focus-visible:ring-[#3b82f6]" className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-[#444] hover:text-[#888] transition-all focus:outline-none focus:opacity-100 focus-visible:ring-1 focus-visible:ring-[#E67E22]"
> >
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true"> <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
@@ -370,7 +370,7 @@ function NotificationItem({ notification: n }: { notification: Notification }) {
<span className={`text-[9px] font-bold uppercase tracking-wide ${cfg.color}`}>{cfg.label}</span> <span className={`text-[9px] font-bold uppercase tracking-wide ${cfg.color}`}>{cfg.label}</span>
<span className="text-[9px] text-[#444]">{timeAgo(n.created_at)}</span> <span className="text-[9px] text-[#444]">{timeAgo(n.created_at)}</span>
{!n.is_read && ( {!n.is_read && (
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] flex-shrink-0" aria-label="Unread" /> <span className="w-1.5 h-1.5 rounded-full bg-[#E67E22] flex-shrink-0" aria-label="Unread" />
)} )}
</div> </div>
</div> </div>
@@ -379,7 +379,7 @@ function NotificationItem({ notification: n }: { notification: Notification }) {
if (n.link) { if (n.link) {
return ( return (
<Link href={n.link} className="block focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#3b82f6]"> <Link href={n.link} className="block focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[#E67E22]">
{content} {content}
</Link> </Link>
); );

View File

@@ -8,10 +8,10 @@ import { useEffect, useState } from "react";
* On mount we assemble the address client-side and wrap in a real mailto. * On mount we assemble the address client-side and wrap in a real mailto.
* *
* Pre-mount HTML looks like: * Pre-mount HTML looks like:
* <span data-u="ryan" data-d="broadcastbeat.com">[email protected]</span> * <span data-u="ryan" data-d="avbeat.com">[email protected]</span>
* *
* Post-mount (JS executed) becomes: * Post-mount (JS executed) becomes:
* <a href="mailto:ryan@broadcastbeat.com">ryan@broadcastbeat.com</a> * <a href="mailto:ryan@avbeat.com">ryan@avbeat.com</a>
* *
* Plus the @ is rendered with an HTML entity so even rare bots reading the * Plus the @ is rendered with an HTML entity so even rare bots reading the
* pre-JS DOM won't get a clean string. * pre-JS DOM won't get a clean string.

View File

@@ -34,7 +34,7 @@ const ToolbarButton = ({
title={title} title={title}
className={`p-1.5 rounded transition-colors text-sm ${ className={`p-1.5 rounded transition-colors text-sm ${
active active
? 'bg-[#3b82f6] text-white' ? 'bg-[#E67E22] text-white'
: 'text-[#888] hover:text-[#cccccc] hover:bg-[#252525]' : 'text-[#888] hover:text-[#cccccc] hover:bg-[#252525]'
}`} }`}
> >
@@ -53,7 +53,7 @@ export default function RichTextEditor({ value, onChange, placeholder = 'Start w
Color, Color,
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
Image.configure({ inline: false, allowBase64: true }), Image.configure({ inline: false, allowBase64: true }),
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-[#3b82f6] underline' } }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-[#E67E22] underline' } }),
Placeholder.configure({ placeholder }), Placeholder.configure({ placeholder }),
], ],
content: value, content: value,

View File

@@ -70,11 +70,11 @@ export default async function SponsorLogoStrip() {
return ( return (
<section className="max-w-container mx-auto px-4 py-6 border-t border-b border-[#1a1a1a]" aria-label="Content partners"> <section className="max-w-container mx-auto px-4 py-6 border-t border-b border-[#1a1a1a]" aria-label="Content partners">
<div className="flex items-center gap-4 mb-3"> <div className="flex items-center gap-4 mb-3">
<span className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-bold"> <span className="text-[10px] uppercase tracking-widest text-[#E67E22] font-bold">
Content Partners Content Partners
</span> </span>
<div className="flex-1 h-px bg-[#1a1a1a]" /> <div className="flex-1 h-px bg-[#1a1a1a]" />
<Link href="/advertise" className="text-[10px] text-[#666] hover:text-[#3b82f6] font-mono"> <Link href="/advertise" className="text-[10px] text-[#666] hover:text-[#E67E22] font-mono">
Advertise Advertise
</Link> </Link>
</div> </div>

View File

@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
/**
* Social + magic-link auth buttons. Drops into the existing login + register
* pages. Every provider must be enabled in the Supabase dashboard
* (Authentication → Providers) and have its client ID/secret + redirect
* URL configured. Providers that aren't enabled yet just no-op silently
* if clicked — Supabase returns a 400 we surface as an error.
*
* Provider config Ryan needs to complete (one-time, ~10 min each):
* - Google → console.cloud.google.com → OAuth client ID
* - Azure (MS) → entra.microsoft.com → App registration
* - LinkedIn → linkedin.com/developers → App
* - Apple → developer.apple.com → Services ID (optional, for iOS later)
*
* Magic link (Email OTP) works with zero provider config — uses Supabase's
* own SMTP path (which already routes through Brevo).
*/
type Provider = "google" | "azure" | "linkedin_oidc";
const PROVIDERS: { id: Provider; label: string; icon: string }[] = [
{ id: "google", label: "Continue with Google", icon: "G" },
{ id: "azure", label: "Continue with Microsoft", icon: "▦" },
{ id: "linkedin_oidc", label: "Continue with LinkedIn", icon: "in" },
];
export default function SocialAuthButtons({ next = "/forum" }: { next?: string }) {
const supabase = createClient();
const [busy, setBusy] = useState<Provider | "magic" | null>(null);
const [email, setEmail] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [showMagic, setShowMagic] = useState(false);
async function oauth(p: Provider) {
setBusy(p); setErr(null); setMsg(null);
const { error } = await supabase.auth.signInWithOAuth({
provider: p,
options: {
redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
if (error) { setErr(error.message); setBusy(null); }
// Supabase navigates to the provider — no need to flip busy on success
}
async function magicLink(e: React.FormEvent) {
e.preventDefault();
setBusy("magic"); setErr(null); setMsg(null);
const { error } = await supabase.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
shouldCreateUser: true,
},
});
setBusy(null);
if (error) { setErr(error.message); }
else setMsg("Check your inbox — we sent a one-time login link.");
}
return (
<div className="space-y-2 pt-3 border-t border-[#252525]">
<div className="text-center text-[10px] uppercase tracking-widest text-[#666] py-1">or</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{PROVIDERS.map((p) => (
<button
key={p.id}
type="button"
disabled={!!busy}
onClick={() => oauth(p.id)}
className="bg-[#0d0d0d] border border-[#252525] hover:border-[#E67E22] text-white text-xs font-semibold py-2 px-3 rounded-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-colors"
>
<span className="text-base leading-none">{p.icon}</span>
<span>{busy === p.id ? "Redirecting…" : p.label}</span>
</button>
))}
</div>
{showMagic ? (
<form onSubmit={magicLink} className="bg-[#0d0d0d] border border-[#252525] rounded-lg p-3 space-y-2">
<p className="text-[11px] text-[#888]">We&rsquo;ll send a one-time login link no password needed.</p>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full bg-[#111] border border-[#2a2a2a] rounded px-2 py-1.5 text-white text-sm"
/>
{err && <p className="text-[11px] text-red-400">{err}</p>}
{msg && <p className="text-[11px] text-emerald-400">{msg}</p>}
<button
type="submit"
disabled={busy === "magic" || !email.trim()}
className="w-full bg-[#E67E22] hover:bg-[#C8651B] text-white text-xs font-semibold py-1.5 rounded disabled:opacity-50"
>
{busy === "magic" ? "Sending…" : "Send login link"}
</button>
</form>
) : (
<button
type="button"
onClick={() => setShowMagic(true)}
className="w-full bg-[#0d0d0d] border border-dashed border-[#E67E22]/50 hover:border-[#E67E22] text-[#E67E22] text-xs font-semibold py-2 rounded-lg transition-colors"
>
📧 Email me a one-time login link (no password)
</button>
)}
{err && !showMagic && <p className="text-[11px] text-red-400 text-center">{err}</p>}
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useRef, useState } from "react";
/**
* Drop-in "📎 Add image" button for any composer textarea. Wires
* straight to the shared /api/upload/image pipeline (#62) so every
* upload across the site is optimized the same way.
*
* On successful upload, the markdown `![alt](primaryUrl)` is appended
* to the textarea via the onInsert callback — bodies stay markdown so
* server-side rendering can swap to <picture> with AVIF on read.
*
* Props:
* onInsert(markdown) — called with the markdown to append
* disabled? — optional manual disable
* maxBytes? — client-side guard (default 20 MB)
*/
interface Props {
onInsert: (markdown: string) => void;
disabled?: boolean;
maxBytes?: number;
}
export default function ImageAttachButton({ onInsert, disabled, maxBytes = 20 * 1024 * 1024 }: Props) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function handleFile(file: File) {
setErr(null);
if (file.size > maxBytes) {
setErr(`Too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 20 MB.`);
return;
}
if (!file.type.startsWith("image/")) {
setErr("Not an image file.");
return;
}
setBusy(true);
try {
const fd = new FormData();
fd.append("file", file);
const r = await fetch("/api/upload/image", { method: "POST", body: fd });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
setErr(j.error || `Upload failed (${r.status})`);
} else if (j.primaryUrl) {
const alt = file.name.replace(/\.[^.]+$/, "");
onInsert(`\n\n![${alt}](${j.primaryUrl})\n\n`);
} else {
setErr("Upload succeeded but no URL returned.");
}
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : "Upload failed");
} finally {
setBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<div className="inline-flex flex-col">
<div className="inline-flex items-center gap-2">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy || disabled}
className="inline-flex items-center gap-1.5 bg-[#252525] hover:bg-[#333] disabled:opacity-50 text-[#aaa] font-body text-xs px-3 py-1.5 rounded-md transition-colors border border-[#333]"
title="Attach an image (auto-optimized, max 20 MB)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 1 1-8.49-8.49l9.19-9.19a4 4 0 1 1 5.66 5.66l-9.2 9.19a2 2 0 1 1-2.83-2.83l8.49-8.48" />
</svg>
{busy ? "Uploading…" : "Attach image"}
</button>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
</div>
{err && <div className="text-[11px] text-red-400 mt-1">{err}</div>}
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import LinkifyPlainText from "@/components/LinkifyPlainText";
/**
* Forum body renderer that splits markdown image syntax out of the
* plain-text body, renders inline `![alt](url)` images as a <picture>
* element with AVIF source (when the URL is from our bb-media bucket,
* we derive the .avif sibling), and hands the rest of the prose to
* LinkifyPlainText for company auto-linking.
*
* Why a custom mini-parser instead of react-markdown? The body is
* 99% plain text with the occasional attached image. Pulling in a
* full markdown lib + sanitizer chain for one feature triples the
* client bundle for negligible gain.
*/
const IMG_RE = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
function deriveAvifFromWebp(url: string): string | null {
// Our /api/upload/image writes both .webp and .avif to the same path.
// If the URL ends in .webp on the bb-media bucket, the .avif sibling
// is at the same path with the extension swapped.
if (!/\/bb-media\/.+\.webp(\?.*)?$/i.test(url)) return null;
return url.replace(/\.webp(\?.*)?$/i, ".avif$1");
}
export default function RichBody({ body }: { body: string }) {
if (!body) return null;
// Walk the body, splitting on image markdown.
const segments: Array<
| { type: "text"; text: string }
| { type: "image"; alt: string; url: string }
> = [];
let cursor = 0;
IMG_RE.lastIndex = 0;
for (let m = IMG_RE.exec(body); m; m = IMG_RE.exec(body)) {
if (m.index > cursor) {
segments.push({ type: "text", text: body.slice(cursor, m.index) });
}
segments.push({ type: "image", alt: m[1] || "", url: m[2] });
cursor = m.index + m[0].length;
}
if (cursor < body.length) {
segments.push({ type: "text", text: body.slice(cursor) });
}
if (segments.length === 0) {
segments.push({ type: "text", text: body });
}
return (
<div className="space-y-3">
{segments.map((seg, i) => {
if (seg.type === "image") {
const avif = deriveAvifFromWebp(seg.url);
return (
<figure key={i} className="my-2">
<picture>
{avif && <source srcSet={avif} type="image/avif" />}
<source srcSet={seg.url} type="image/webp" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={seg.url}
alt={seg.alt}
loading="lazy"
decoding="async"
className="max-w-full h-auto rounded-md border border-[#252525] bg-[#0d0d0d]"
/>
</picture>
{seg.alt && (
<figcaption className="text-[11px] text-[#666] mt-1 italic">{seg.alt}</figcaption>
)}
</figure>
);
}
// For text segments, drop into LinkifyPlainText (company links)
return seg.text.trim()
? <LinkifyPlainText key={i} body={seg.text} />
: null;
})}
</div>
);
}

View File

@@ -408,6 +408,99 @@ export async function searchLegacyArticles(
return merged.slice(0, limit).map((m) => m.article); return merged.slice(0, limit).map((m) => m.article);
} }
/** Pick keywords from an article title to match against forum threads.
Drops short/common words; keeps brand/product nouns. */
function titleKeywords(s: string): string[] {
const STOP = new Set([
"the","a","an","and","or","but","of","for","to","in","on","at","with","from",
"by","as","is","are","was","were","be","been","being","this","that","these",
"those","it","its","new","now","more","most","how","why","when","where","what",
"who","which","also","plus","via","over","under","into","than","then","each",
"all","any","every","some","just","one","two","three","four","five","six",
]);
return Array.from(new Set(
s.toLowerCase()
.replace(/[^a-z0-9 -]+/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 4 && !STOP.has(w))
)).slice(0, 6);
}
export interface RelatedForumThread {
id: string;
title: string;
body: string;
reply_count: number;
view_count: number;
vote_score: number;
last_reply_at: string | null;
category_slug: string | null;
category_name: string | null;
}
/** Find forum threads relevant to an article's title. Strategy: extract
longish title tokens, match any against forum_threads.title (ILIKE OR),
rank by reply_count + recency, cap at `limit`. Falls back to recently-
active threads if no keyword matches. */
export async function getRelatedForumThreads(
articleTitle: string,
limit = 6,
): Promise<RelatedForumThread[]> {
const kws = titleKeywords(articleTitle);
let rows: any[] = [];
if (kws.length > 0) {
const filter = kws.map((k) => `title.ilike.%${k.replace(/[%_]/g, "")}%`).join(",");
try {
const { data } = await client()
.from("forum_threads")
.select("id, title, body, reply_count, view_count, vote_score, last_reply_at, category_id")
.or(filter)
.order("reply_count", { ascending: false })
.order("last_reply_at", { ascending: false, nullsFirst: false })
.limit(limit * 2);
rows = data || [];
} catch { /* ignore */ }
}
if (rows.length < limit) {
// Top up with recently-active threads so we never render an empty box.
try {
const { data } = await client()
.from("forum_threads")
.select("id, title, body, reply_count, view_count, vote_score, last_reply_at, category_id")
.order("last_reply_at", { ascending: false, nullsFirst: false })
.limit(limit * 2);
const seen = new Set(rows.map((r) => r.id));
for (const r of data || []) {
if (rows.length >= limit * 2) break;
if (!seen.has(r.id)) rows.push(r);
}
} catch { /* ignore */ }
}
// Resolve category slug/name in one batch
const catIds = Array.from(new Set(rows.map((r) => r.category_id).filter(Boolean)));
let catById = new Map<string, { slug: string; name: string }>();
if (catIds.length) {
try {
const { data } = await client()
.from("forum_categories")
.select("id, slug, name")
.in("id", catIds);
for (const c of data || []) catById.set(c.id, { slug: c.slug, name: c.name });
} catch { /* ignore */ }
}
return rows.slice(0, limit).map((r) => ({
id: r.id,
title: r.title,
body: r.body,
reply_count: r.reply_count ?? 0,
view_count: r.view_count ?? 0,
vote_score: r.vote_score ?? 0,
last_reply_at: r.last_reply_at,
category_slug: catById.get(r.category_id)?.slug || null,
category_name: catById.get(r.category_id)?.name || null,
}));
}
export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> { export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
let legacy: string[] = []; let legacy: string[] = [];
let rewrites: string[] = []; let rewrites: string[] = [];

139
src/lib/images/optimize.ts Normal file
View File

@@ -0,0 +1,139 @@
/**
* Unified server-side image optimization pipeline.
*
* Every image upload across the site flows through this module: PR firm
* featured images, contributor uploads, forum post attachments, author
* avatars, banner creatives, anything else. Single source of truth so
* every surface produces the same multi-format ladder and stays under
* sane size caps.
*
* Output per input:
* - AVIF (best compression, all current browsers)
* - WebP (universal fallback)
* - JPEG (final fallback for very old clients, preserved if input was JPEG)
* - thumbnail (320px, JPEG) for in-feed cards
*
* Quality: q=82 visually lossless for photos. Compression by default keeps
* transparency on PNGs.
*
* Resize policy: longest-side cap of 4096px (prevents 12MP DSLR exports from
* sitting un-resized on our CDN). Caller can override `maxSide`.
*
* Returns Buffers for the caller to upload to Supabase Storage.
*/
import sharp from "sharp";
export interface OptimizeOptions {
/** Longest side in pixels — default 4096. */
maxSide?: number;
/** Compression quality 1-100 — default 82 (perceptually lossless for photos). */
quality?: number;
/** Skip thumbnail generation when caller doesn't need it. */
thumbnail?: boolean;
/** Skip AVIF (slow encode) when we only need WebP. */
avif?: boolean;
}
export interface OptimizedImage {
width: number;
height: number;
format: string; // original input format
hasAlpha: boolean;
avif?: Buffer;
webp: Buffer;
jpeg?: Buffer;
png?: Buffer;
thumbnail?: Buffer; // always JPEG, 320px wide
bytes: {
original: number;
avif?: number;
webp: number;
jpeg?: number;
png?: number;
thumbnail?: number;
};
}
export async function optimizeImage(
input: Buffer,
opts: OptimizeOptions = {},
): Promise<OptimizedImage> {
const maxSide = opts.maxSide ?? 4096;
const quality = opts.quality ?? 82;
const wantAvif = opts.avif !== false;
const wantThumb = opts.thumbnail !== false;
// Read metadata up-front so we know what we're dealing with.
const meta = await sharp(input, { failOn: "none" }).metadata();
const inputFormat = (meta.format || "unknown").toLowerCase();
const hasAlpha = !!meta.hasAlpha;
const inputWidth = meta.width || 0;
const inputHeight = meta.height || 0;
// Build a pipeline that's rotation-aware (EXIF) and resized when needed.
function pipeline() {
let p = sharp(input, { failOn: "none" }).rotate();
if (inputWidth > maxSide || inputHeight > maxSide) {
p = p.resize({ width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true });
}
return p;
}
const out: OptimizedImage = {
width: inputWidth,
height: inputHeight,
format: inputFormat,
hasAlpha,
webp: Buffer.alloc(0),
bytes: { original: input.byteLength, webp: 0 },
};
// ── WebP (always; universal modern fallback) ──────────────────────────────
out.webp = await pipeline()
.webp({ quality, effort: 4, alphaQuality: hasAlpha ? quality : undefined })
.toBuffer();
out.bytes.webp = out.webp.byteLength;
// ── AVIF (better compression, slower encode) ──────────────────────────────
if (wantAvif) {
try {
out.avif = await pipeline()
.avif({ quality, effort: 4 })
.toBuffer();
out.bytes.avif = out.avif.byteLength;
} catch {
// libvips without AVIF support — skip, WebP covers us
}
}
// ── Preserve original format for legacy fallback ──────────────────────────
if (hasAlpha) {
out.png = await pipeline()
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer();
out.bytes.png = out.png.byteLength;
} else {
out.jpeg = await pipeline()
.jpeg({ quality, progressive: true, mozjpeg: true })
.toBuffer();
out.bytes.jpeg = out.jpeg.byteLength;
}
// ── Thumbnail (320px wide JPEG, no transparency — feed/card use only) ─────
if (wantThumb) {
out.thumbnail = await sharp(input, { failOn: "none" })
.rotate()
.resize({ width: 320, height: 240, fit: "cover", position: "centre" })
.jpeg({ quality: 80, progressive: true, mozjpeg: true })
.toBuffer();
out.bytes.thumbnail = out.thumbnail.byteLength;
}
return out;
}
/** Convenience: only need a single optimized buffer in best modern format. */
export async function optimizeToWebp(input: Buffer, opts: OptimizeOptions = {}) {
const r = await optimizeImage(input, { ...opts, avif: false, thumbnail: false });
return { buffer: r.webp, width: r.width, height: r.height, bytes: r.bytes };
}

View File

@@ -18,6 +18,6 @@ export function createAdminClient(schema?: string) {
} }
return createClient(url, key, { return createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
db: { schema: schema || process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb" }, db: { schema: schema || process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "avb" },
}); });
} }

View File

@@ -5,7 +5,7 @@ export function createClient() {
process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ {
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'bb' }, db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'avb' },
cookies: { cookies: {
getAll() { getAll() {
if (typeof document === 'undefined') return []; if (typeof document === 'undefined') return [];

View File

@@ -8,7 +8,7 @@ export async function createClient() {
process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ {
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'bb' }, db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'avb' },
cookies: { cookies: {
getAll() { getAll() {
return cookieStore.getAll(); return cookieStore.getAll();

View File

@@ -3,21 +3,22 @@
homepage components. Existing tokens kept intact (forward-only). */ homepage components. Existing tokens kept intact (forward-only). */
:root { :root {
/* AI accent (blue) — same hue as --color-accent but exposed via the /* AV Beat palette — orange-led, near-black canvas. The legacy
spec name --color-text-info so the redesign components can reference `--color-text-info` and `*-strong` names retain their semantic
it without coupling to the existing --color-accent. */ meaning (informational accent) but now carry the AV orange so
--color-text-info: #60a5fa; ticker dots, AI rings and inline accents read as AV Beat. */
--color-text-info-strong: #3b82f6; --color-text-info: #E67E22;
--color-text-info-strong: #E67E22;
/* Backgrounds */ /* Backgrounds */
--color-background-primary: #0a0a0a; --color-background-primary: #0F0E0E;
--color-background-secondary: #111111; --color-background-secondary: #1D1A1A;
--color-background-tertiary: #1a1f2e; --color-background-tertiary: #221c18;
/* Text */ /* Text */
--color-text-primary: #e8e8e8; --color-text-primary: #F0EBE6;
--color-text-secondary: #b0b0b0; --color-text-secondary: #b8b1aa;
--color-text-tertiary: #6b7280; --color-text-tertiary: #6b6560;
/* Brand surfaces */ /* Brand surfaces */
--color-text-success: #10b981; --color-text-success: #10b981;

View File

@@ -3,20 +3,21 @@
@tailwind utilities; @tailwind utilities;
:root { :root {
--color-primary: #0d0d0d; /* AV Beat palette — orange/near-black, "Where AV Meets IT" */
--color-secondary: #161616; --color-primary: #0F0E0E;
--color-accent: #3b82f6; --color-secondary: #1D1A1A;
--color-accent-dark: #2563eb; --color-accent: #E67E22;
--color-accent-muted: #1e3a5f; --color-accent-dark: #C8651B;
--color-accent-red: #cc0000; --color-accent-muted: #5a3413;
--color-accent-red-dark: #aa0000; --color-accent-red: #E67E22;
--color-bg: #111111; --color-accent-red-dark: #C8651B;
--color-card: #1a1a1a; --color-bg: #0F0E0E;
--color-foreground: #e8e8e8; --color-card: #1D1A1A;
--color-muted: #888888; --color-foreground: #F0EBE6;
--color-border: #2a2a2a; --color-muted: #8a847e;
--color-top-bar: #1a2535; --color-border: #2a2624;
--color-sub-nav: #161c28; --color-top-bar: #1D1A1A;
--color-sub-nav: #161313;
/* --font-serif and --font-sans are injected on <html> by next/font/google /* --font-serif and --font-sans are injected on <html> by next/font/google
(Lora + Inter) in src/app/layout.tsx. The system-font tails are real (Lora + Inter) in src/app/layout.tsx. The system-font tails are real
fallbacks for the brief window before the self-hosted face is ready. */ fallbacks for the brief window before the self-hosted face is ready. */

View File

@@ -8,23 +8,24 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: '#0d0d0d', // AV Beat palette — orange/near-black, "Where AV Meets IT"
secondary: '#161616', primary: '#0F0E0E',
accent: '#3b82f6', secondary: '#1D1A1A',
'accent-dark': '#2563eb', accent: '#E67E22',
'accent-muted': '#1e3a5f', 'accent-dark': '#C8651B',
'accent-red': '#cc0000', 'accent-muted': '#5a3413',
'accent-red-dark': '#aa0000', 'accent-red': '#E67E22',
background: '#111111', 'accent-red-dark': '#C8651B',
card: '#1a1a1a', background: '#0F0E0E',
foreground: '#e8e8e8', card: '#1D1A1A',
muted: '#888888', foreground: '#F0EBE6',
border: '#2a2a2a', muted: '#8a847e',
'navy-mid': '#1e1e1e', border: '#2a2624',
'navy-light': '#222222', 'navy-mid': '#1e1a18',
'ice-blue': '#1a2535', 'navy-light': '#221c18',
'top-bar': '#1a2535', 'ice-blue': '#1D1A1A',
'sub-nav': '#161c28', 'top-bar': '#1D1A1A',
'sub-nav': '#161313',
}, },
fontFamily: { fontFamily: {
heading: ['Georgia', 'serif'], heading: ['Georgia', 'serif'],