Compare commits
12 Commits
a045d6c558
...
pre-bb-clo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd93a74bdf | ||
|
|
c9b85b79b7 | ||
|
|
d67a2bd48d | ||
|
|
6a48473905 | ||
|
|
6b9251ca9b | ||
|
|
e6095dd803 | ||
|
|
445a11a1ee | ||
|
|
891f2dd341 | ||
|
|
0ede78fd35 | ||
|
|
39e9777e0a | ||
|
|
e24d78cd15 | ||
|
|
976c979e54 |
@@ -14,7 +14,11 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=$NEXT_PUBLIC_GA_MEASUREMENT_ID
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
# Force dev-deps even if NODE_ENV=production was supplied as a Coolify
|
||||
# build arg — autoprefixer/postcss/tailwindcss live in devDependencies and
|
||||
# Next.js needs them at build time. Same fix that landed for RMP cold-build
|
||||
# on 2026-05-30.
|
||||
RUN npm install --include=dev
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "broadcastbeat",
|
||||
"name": "avbeat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"rocketCritical": {
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 286 KiB |
BIN
public/assets/images/personas/chloe-delgado.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/assets/images/personas/darren-okafor.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/assets/images/personas/elena-vasquez.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/assets/images/personas/jeff-victor.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/assets/images/personas/marcus-halverson.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/assets/images/personas/mike-trayton.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/assets/images/personas/priya-rao.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/assets/images/personas/riley-park.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
public/assets/images/personas/sarah-quinn.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/assets/logos/avbeat.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 286 KiB |
@@ -2,13 +2,13 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
|
||||
export const metadata = { title: "Contact — Broadcast Beat" };
|
||||
export const metadata = { title: "Contact — AV Beat" };
|
||||
|
||||
const CONTACTS = [
|
||||
{
|
||||
title: "Editorial",
|
||||
body: (
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@broadcastbeat.com">editor@broadcastbeat.com</a>
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
|
||||
),
|
||||
hint: "Story tips, corrections, interview requests.",
|
||||
},
|
||||
@@ -26,7 +26,7 @@ const CONTACTS = [
|
||||
title: "Advertising",
|
||||
body: (
|
||||
<>
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@broadcastbeat.com">advertising@broadcastbeat.com</a> — see the{" "}
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@avbeat.com">advertising@avbeat.com</a> — see the{" "}
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">media kit</a>.
|
||||
</>
|
||||
),
|
||||
@@ -52,8 +52,8 @@ export default function ContactPage() {
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||
<main>
|
||||
<header className="mb-8 border-b border-[#252525] pb-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
Broadcast Beat
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#E67E22)] mb-2">
|
||||
AV Beat · Where AV Meets IT
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1>
|
||||
<p className="mt-3 text-[#9ca3af] text-base">
|
||||
|
||||
@@ -3,8 +3,8 @@ import Footer from "@/components/Footer";
|
||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
|
||||
export const metadata = {
|
||||
title: "About Broadcast Beat",
|
||||
description: "Broadcast Beat is an independent industry publication covering broadcast, production, and post-production technology.",
|
||||
title: "About AV Beat",
|
||||
description: "AV Beat is an independent industry publication covering the convergence of pro AV and IT — broadcast, live production, streaming, and post-production technology.",
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
@@ -15,12 +15,12 @@ export default function AboutPage() {
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||
<main>
|
||||
<header className="mb-8 border-b border-[#252525] pb-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
Broadcast Beat
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#E67E22)] mb-2">
|
||||
AV Beat · Where AV Meets IT
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">About</h1>
|
||||
<p className="mt-3 text-[#9ca3af] text-base max-w-2xl">
|
||||
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>
|
||||
</header>
|
||||
|
||||
@@ -32,11 +32,11 @@ export default function AboutPage() {
|
||||
|
||||
<section className="space-y-6 text-[#d1d5db] text-[15px] leading-relaxed">
|
||||
<Block title="What we cover">
|
||||
Broadcast Beat reports on the equipment, services, and people
|
||||
that move the professional video industry forward. We follow
|
||||
product launches, executive moves, acquisitions, and the live
|
||||
production / IP / cloud workflows reshaping how content gets
|
||||
made and delivered.
|
||||
AV Beat reports on the equipment, services, and people that
|
||||
move the professional AV and IT-converged video industry
|
||||
forward. We follow product launches, executive moves,
|
||||
acquisitions, and the live production / IP / cloud workflows
|
||||
reshaping how content gets made and delivered.
|
||||
</Block>
|
||||
|
||||
<Block title="Where we go">
|
||||
@@ -57,9 +57,9 @@ export default function AboutPage() {
|
||||
</Block>
|
||||
|
||||
<Block title="Get involved">
|
||||
For editorial pitches: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@broadcastbeat.com">editor@broadcastbeat.com</a>.
|
||||
{" "}For advertising: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">see the media kit</a>.
|
||||
{" "}For our team: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/team">meet the editorial team</a>.
|
||||
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,#E67E22)] hover:underline" href="/about/advertise">see the media kit</a>.
|
||||
{" "}For our team: <a className="text-[var(--color-text-info,#E67E22)] hover:underline" href="/about/team">meet the editorial team</a>.
|
||||
</Block>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export const metadata = { title: "Press kit — Broadcast Beat" };
|
||||
export const metadata = { title: "Press kit — AV Beat" };
|
||||
|
||||
export default function PressKitPage() {
|
||||
return (
|
||||
@@ -13,23 +13,23 @@ export default function PressKitPage() {
|
||||
<section>
|
||||
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Logos</h2>
|
||||
<ul className="space-y-1">
|
||||
<li><a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/assets/images/logo.png" download>Broadcast Beat logo (PNG)</a></li>
|
||||
<li><a className="text-[var(--color-text-info,#E67E22)] hover:underline" href="/assets/images/logo.png" download>AV Beat logo (PNG)</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Boilerplate</h2>
|
||||
<p className="text-[#d1d5db] leading-relaxed">
|
||||
Broadcast Beat is an independent trade publication covering broadcast,
|
||||
live production, streaming, and post-production technology. Founded
|
||||
to bring engineering-rigor reporting to industry coverage, Broadcast Beat
|
||||
publishes daily on the equipment, services, and people that move
|
||||
professional video forward.
|
||||
AV Beat is an independent trade publication covering the convergence
|
||||
of pro AV and IT — broadcast, live production, streaming, and
|
||||
post-production technology. Founded to bring engineering-rigor
|
||||
reporting to industry coverage, AV Beat publishes daily on the
|
||||
equipment, services, and people moving professional video forward.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Media contact</h2>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ export const dynamic = "force-dynamic";
|
||||
export const revalidate = 600;
|
||||
|
||||
export const metadata = {
|
||||
title: "Our team — Broadcast Beat",
|
||||
description: "Meet the Broadcast Beat team — editorial staff plus accounts and support, covering broadcast, streaming, and post-production.",
|
||||
title: "Our team — AV Beat",
|
||||
description: "Meet the AV Beat team — editorial staff plus accounts and support, covering the convergence of pro AV and IT.",
|
||||
};
|
||||
|
||||
interface Persona {
|
||||
@@ -27,6 +27,7 @@ interface OpsPersona {
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
extension: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export default async function TeamPage() {
|
||||
@@ -60,9 +61,9 @@ export default async function TeamPage() {
|
||||
);
|
||||
const { data: opsData } = await sbOps
|
||||
.from("personas")
|
||||
.select("id, display_name, role, email, phone, extension")
|
||||
.select("id, display_name, role, email, phone, extension, avatar_url")
|
||||
.eq("active", true)
|
||||
.in("id", ["chloe", "riley"]);
|
||||
.in("id", ["chloe", "jeff", "riley"]);
|
||||
const ops = (opsData || []) as OpsPersona[];
|
||||
|
||||
const displayBeat = (p: Persona) =>
|
||||
@@ -76,11 +77,11 @@ export default async function TeamPage() {
|
||||
.map((w) => w.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
|
||||
// Derive @broadcastbeat.com email from the persona slug. Real mailboxes
|
||||
// Derive @avbeat.com email from the persona slug. Real mailboxes
|
||||
// are set up centrally; this is the authoring address surfaced to readers.
|
||||
const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
|
||||
if (!p.slug) return null;
|
||||
return { user: p.slug, domain: "broadcastbeat.com" };
|
||||
return { user: p.slug.replace(/-/g, "."), domain: "avbeat.com" };
|
||||
};
|
||||
|
||||
const splitEmail = (email: string): { user: string; domain: string } | null => {
|
||||
@@ -95,82 +96,152 @@ export default async function TeamPage() {
|
||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||
<main>
|
||||
<header className="mb-8 border-b border-[#252525] pb-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
Broadcast Beat
|
||||
{/* Hero */}
|
||||
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
|
||||
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#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 · editorial
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
||||
The team behind every byline
|
||||
</h1>
|
||||
<p className="mt-4 text-[#9ca3af] text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
Beats covered by trained AI personas working under house style, plus the
|
||||
business and support people who keep things moving.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Editorial team</h1>
|
||||
<p className="mt-3 text-[#9ca3af] text-base">
|
||||
Staff journalists by beat — a mix of human reporters and trained AI personas operating under house style.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{team.map((p) => (
|
||||
<li key={p.slug} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
|
||||
{p.avatar_url && (
|
||||
<img
|
||||
src={p.avatar_url}
|
||||
alt={p.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full border border-[#252525] flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-serif text-lg font-semibold">{p.name}</h2>
|
||||
{(p.beat || p.slug === "ryan-salazar") && (
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
|
||||
{displayBeat(p)}
|
||||
</div>
|
||||
)}
|
||||
{p.bio && <p className="text-sm text-[#9ca3af] mt-2 leading-relaxed">{p.bio}</p>}
|
||||
{(() => {
|
||||
const e = editorialEmail(p);
|
||||
if (!e) return null;
|
||||
return (
|
||||
<p className="text-xs text-[#9ca3af] mt-2">
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Editorial team — three-col grid on desktop, larger portraits */}
|
||||
<section className="mb-14">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||
{team.length} {team.length === 1 ? "writer" : "writers"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ops.length > 0 && (
|
||||
<>
|
||||
<header className="mt-12 mb-6 border-b border-[#252525] pb-4">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Accounts & support</h2>
|
||||
<p className="mt-2 text-[#9ca3af] text-sm">
|
||||
For billing, accounts receivable, vendor onboarding, and general support questions — these are the people to reach.
|
||||
</p>
|
||||
</header>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{ops.map((p) => (
|
||||
<li key={p.id} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
|
||||
<div className="w-16 h-16 rounded-full border border-[#252525] flex-shrink-0 bg-[#1a1a1a] flex items-center justify-center font-serif font-bold text-xl text-[#60a5fa]">
|
||||
{initials(p.display_name)}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{team.map((p) => {
|
||||
const e = editorialEmail(p);
|
||||
return (
|
||||
<li
|
||||
key={p.slug}
|
||||
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#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
|
||||
src={p.avatar_url}
|
||||
alt={p.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] group-hover:border-[#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">
|
||||
<a href={`/authors/${p.slug}`} className="block hover:underline">
|
||||
<h3 className="font-serif text-xl font-bold leading-tight">{p.name}</h3>
|
||||
</a>
|
||||
{(p.beat || p.slug === "ryan-salazar") && (
|
||||
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#E67E22]/10 border border-[#E67E22]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#F0B07A]">
|
||||
{displayBeat(p)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.bio && (
|
||||
<p className="text-sm text-[#aab1bd] mt-4 leading-relaxed line-clamp-4">
|
||||
{p.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{e && (
|
||||
<div className="mt-4 pt-4 border-t border-[#1a1f2a] flex items-center justify-between text-xs">
|
||||
<ObfuscatedEmail
|
||||
user={e.user}
|
||||
domain={e.domain}
|
||||
className="text-[#aab1bd] hover:text-[#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 & support
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
|
||||
For billing, accounts receivable, vendor onboarding, sales, and
|
||||
general support questions — these are the people to reach.
|
||||
</p>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{ops.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 flex gap-4 hover:border-[#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)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-serif text-lg font-semibold">{p.display_name}</h3>
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
|
||||
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
|
||||
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#1a2535] border border-[#3b82f6]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]">
|
||||
{p.role}
|
||||
</div>
|
||||
<div className="text-xs text-[#9ca3af] mt-2 space-y-0.5">
|
||||
<div className="text-xs text-[#aab1bd] mt-3 space-y-1">
|
||||
{p.email && (() => {
|
||||
const e = splitEmail(p.email);
|
||||
if (!e) return null;
|
||||
return (
|
||||
<div>
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
|
||||
<div className="font-mono">
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#F0B07A]" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{p.phone && (
|
||||
<div>
|
||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#60a5fa]">
|
||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#F0B07A] font-mono">
|
||||
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
|
||||
</a>
|
||||
</div>
|
||||
@@ -180,7 +251,7 @@ export default async function TeamPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Advertise With Broadcast Beat — Reach Broadcast Engineering Professionals',
|
||||
description: 'Advertise with Broadcast Beat to reach broadcast engineers, production professionals, and media technology decision-makers. Display ads, sponsored content, and newsletter placements.',
|
||||
title: 'Advertise With AV Beat — Reach Broadcast Engineering Professionals',
|
||||
description: 'Advertise with AV Beat to reach broadcast engineers, production professionals, and media technology decision-makers. Display ads, sponsored content, and newsletter placements.',
|
||||
alternates: { canonical: '/advertise' },
|
||||
openGraph: {
|
||||
title: 'Advertise With Broadcast Beat',
|
||||
title: 'Advertise With AV Beat',
|
||||
description: 'Reach broadcast engineers, production professionals, and media technology decision-makers.',
|
||||
url: '/advertise',
|
||||
type: 'website',
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function AdvertisePage() {
|
||||
<span className="section-label mb-3 inline-block">Advertise</span>
|
||||
<h1 className="font-heading text-white text-4xl font-bold mb-4">Reach Broadcast Engineering Professionals</h1>
|
||||
<p className="text-[#aaa] font-body text-lg max-w-2xl mx-auto mb-6">
|
||||
Broadcast Beat is the leading digital platform for broadcast engineering professionals. Connect your brand with decision-makers, engineers, and executives across the broadcast industry.
|
||||
AV Beat is the leading digital platform for broadcast engineering professionals. Connect your brand with decision-makers, engineers, and executives across the broadcast industry.
|
||||
</p>
|
||||
<a
|
||||
href="#contact"
|
||||
@@ -69,7 +69,7 @@ export default function AdvertisePage() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<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>
|
||||
))}
|
||||
@@ -86,10 +86,10 @@ export default function AdvertisePage() {
|
||||
<table className="w-full text-sm font-body">
|
||||
<thead>
|
||||
<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-[#3b82f6] 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-[#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">Placement</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-[#E67E22] 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">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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-[#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-[#3b82f6] font-medium">{zone.cpm}</td>
|
||||
<td className="py-3 px-4 text-[#E67E22] font-medium">{zone.cpm}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -123,23 +123,23 @@ export default function AdvertisePage() {
|
||||
<h3 className="font-heading text-[#e0e0e0] text-lg font-bold mb-4">Sales Contact</h3>
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<a href="mailto:jeff@broadcastbeat.com" className="font-body text-[#3b82f6] text-sm hover:underline">
|
||||
jeff@broadcastbeat.com
|
||||
<a href="mailto:jeff@avbeat.com" className="font-body text-[#E67E22] text-sm hover:underline">
|
||||
jeff@avbeat.com
|
||||
</a>
|
||||
</div>
|
||||
<div className="border-t border-[#222] pt-4">
|
||||
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Publication</p>
|
||||
<p className="font-body text-[#777] text-sm">Broadcast Beat</p>
|
||||
<p className="font-body text-[#E67E22] text-xs font-bold uppercase tracking-wider mb-1">Publication</p>
|
||||
<p className="font-body text-[#777] text-sm">AV Beat</p>
|
||||
<p className="font-body text-[#777] text-sm">Relevant Media Properties</p>
|
||||
</div>
|
||||
<div className="border-t border-[#222] pt-4">
|
||||
<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">
|
||||
{["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">
|
||||
<span className="text-[#3b82f6] mt-0.5">✓</span>
|
||||
<span className="text-[#E67E22] mt-0.5">✓</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
@@ -152,16 +152,16 @@ export default function AdvertisePage() {
|
||||
{/* Form */}
|
||||
<div className="lg:col-span-8">
|
||||
{submitted ? (
|
||||
<div className="bg-[#111] border border-[#3b82f6] 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="bg-[#111] border border-[#E67E22] p-8 text-center">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 className="font-heading text-[#e0e0e0] text-xl font-bold mb-2">Message Sent!</h3>
|
||||
<p className="font-body text-[#777] text-sm">
|
||||
Thanks for reaching out. Jeff will be in touch within one business day at{" "}
|
||||
<a href="mailto:jeff@broadcastbeat.com" className="text-[#3b82f6] hover:underline">jeff@broadcastbeat.com</a>.
|
||||
<a href="mailto:jeff@avbeat.com" className="text-[#E67E22] hover:underline">jeff@avbeat.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -245,7 +245,7 @@ export default function AdvertisePage() {
|
||||
{loading ? "Sending..." : "Send Inquiry"}
|
||||
</button>
|
||||
<p className="text-[#555] text-xs font-body">
|
||||
Or email directly: <a href="mailto:jeff@broadcastbeat.com" className="text-[#3b82f6] hover:underline">jeff@broadcastbeat.com</a>
|
||||
Or email directly: <a href="mailto:jeff@avbeat.com" className="text-[#E67E22] hover:underline">jeff@avbeat.com</a>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
51
src/app/api/auth/lookup-email/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* GET /api/auth/lookup-email?email=foo@example.com
|
||||
*
|
||||
* Returns { known: true|false }. "Known" means the email is already on our
|
||||
* subscriber list (bb.email_subscribers) OR has an auth.users entry. Used
|
||||
* by the register page so existing newsletter readers get a magic-link
|
||||
* login flow instead of being asked to create a new account.
|
||||
*
|
||||
* Privacy: returns only known/not-known, never any details — does not
|
||||
* leak account existence beyond a binary signal (necessary for the UX).
|
||||
* Rate-limited at the proxy layer.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient as createService } from "@supabase/supabase-js";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const email = (new URL(req.url).searchParams.get("email") || "").trim().toLowerCase();
|
||||
if (!email || !email.includes("@")) {
|
||||
return NextResponse.json({ known: false }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
||||
if (!url || !key) {
|
||||
return NextResponse.json({ known: false });
|
||||
}
|
||||
const svc = createService(url, key, { auth: { persistSession: false } });
|
||||
|
||||
// Layer 1 — the rich subscriber registry (95k+ rows)
|
||||
const { data: sub } = await svc
|
||||
.schema("bb")
|
||||
.from("email_subscribers")
|
||||
.select("id, status")
|
||||
.eq("email_domain", email.split("@")[1])
|
||||
.ilike("email", email)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (sub) return NextResponse.json({ known: true, source: "subscriber" });
|
||||
|
||||
// Layer 2 — the lean property-specific list
|
||||
const { data: nl } = await svc
|
||||
.schema("bb")
|
||||
.from("newsletter_subscribers")
|
||||
.select("id")
|
||||
.ilike("email", email)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (nl) return NextResponse.json({ known: true, source: "newsletter" });
|
||||
|
||||
return NextResponse.json({ known: false });
|
||||
}
|
||||
72
src/app/api/comments/[id]/vote/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* POST /api/comments/[id]/vote { vote: 1 | -1 | 0 }
|
||||
*
|
||||
* Up/down/clear vote on an article comment. Uses the existing
|
||||
* polymorphic forum_votes table (target_type='comment').
|
||||
*
|
||||
* 0 → remove your existing vote (toggle).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
const sb = await createClient();
|
||||
const { data: { user } } = await sb.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "Sign in to vote" }, { status: 401 });
|
||||
|
||||
let body: { vote?: number };
|
||||
try { body = await req.json(); }
|
||||
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
|
||||
|
||||
const v = body.vote === 1 ? 1 : body.vote === -1 ? -1 : 0;
|
||||
|
||||
// Always look up the current vote first so we can compute the diff
|
||||
// and apply it atomically to the comment's denormalized counters.
|
||||
const { data: existing } = await sb
|
||||
.from("forum_votes")
|
||||
.select("id, vote_value")
|
||||
.eq("user_id", user.id)
|
||||
.eq("target_type", "comment")
|
||||
.eq("target_id", id)
|
||||
.maybeSingle();
|
||||
|
||||
let upDelta = 0, downDelta = 0;
|
||||
if (existing) {
|
||||
// Reverse the existing vote's counter contribution first
|
||||
if (existing.vote_value === 1) upDelta -= 1;
|
||||
else if (existing.vote_value === -1) downDelta -= 1;
|
||||
|
||||
if (v === 0) {
|
||||
await sb.from("forum_votes").delete().eq("id", existing.id);
|
||||
} else {
|
||||
await sb.from("forum_votes")
|
||||
.update({ vote_value: v, updated_at: new Date().toISOString() })
|
||||
.eq("id", existing.id);
|
||||
if (v === 1) upDelta += 1; else downDelta += 1;
|
||||
}
|
||||
} else if (v !== 0) {
|
||||
await sb.from("forum_votes").insert({
|
||||
user_id: user.id, target_type: "comment", target_id: id, vote_value: v,
|
||||
});
|
||||
if (v === 1) upDelta += 1; else downDelta += 1;
|
||||
}
|
||||
|
||||
// Apply counter delta to the comment row. Use rpc/raw fragment to add
|
||||
// integers atomically (supabase-js doesn't expose increment; we read &
|
||||
// write — racy under heavy concurrency but acceptable for forum scale).
|
||||
const { data: cur } = await sb
|
||||
.from("article_comments")
|
||||
.select("upvotes, downvotes")
|
||||
.eq("id", id)
|
||||
.maybeSingle();
|
||||
if (cur) {
|
||||
const upvotes = Math.max(0, (cur.upvotes || 0) + upDelta);
|
||||
const downvotes = Math.max(0, (cur.downvotes || 0) + downDelta);
|
||||
await sb.from("article_comments").update({
|
||||
upvotes, downvotes, vote_score: upvotes - downvotes,
|
||||
}).eq("id", id);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, vote: v });
|
||||
}
|
||||
100
src/app/api/comments/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Article comments API.
|
||||
*
|
||||
* GET /api/comments?article_slug=<slug> → list, threaded by parent
|
||||
* POST /api/comments → create (auth required)
|
||||
*
|
||||
* Reads: public — anyone can fetch.
|
||||
* Writes: signed-in users only. We assert via supabase auth and use the
|
||||
* user's forum_user_profile for the denormalized display fields so the
|
||||
* client can render avatar/name without an extra join per comment.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
article_slug: string;
|
||||
parent_comment_id: string | null;
|
||||
user_id: string | null;
|
||||
content: string;
|
||||
author_username: string | null;
|
||||
author_display_name: string | null;
|
||||
author_avatar_url: string | null;
|
||||
is_ai_seeded: boolean;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
vote_score: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const slug = new URL(req.url).searchParams.get("article_slug");
|
||||
if (!slug) return NextResponse.json({ error: "article_slug required" }, { status: 400 });
|
||||
|
||||
const sb = await createClient();
|
||||
const { data, error } = await sb
|
||||
.from("article_comments")
|
||||
.select("*")
|
||||
.eq("article_slug", slug)
|
||||
.eq("status", "published")
|
||||
.order("vote_score", { ascending: false })
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(500);
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ comments: data || [] });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const sb = await createClient();
|
||||
const { data: { user } } = await sb.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Sign in to comment", redirectTo: "/forum/login" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
let body: { article_slug?: string; content?: string; parent_comment_id?: string | null };
|
||||
try { body = await req.json(); }
|
||||
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
|
||||
|
||||
const slug = (body.article_slug || "").trim();
|
||||
const content = (body.content || "").trim();
|
||||
if (!slug || !content) {
|
||||
return NextResponse.json({ error: "article_slug and content required" }, { status: 400 });
|
||||
}
|
||||
if (content.length > 5000) {
|
||||
return NextResponse.json({ error: "Comment too long (5000 char max)" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pull the user's forum profile so we have display name + avatar for
|
||||
// denormalized rendering (saves a per-comment join later).
|
||||
const { data: prof } = await sb
|
||||
.from("forum_user_profiles")
|
||||
.select("username, display_name, avatar_url")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
const ins = {
|
||||
article_slug: slug,
|
||||
user_id: user.id,
|
||||
content,
|
||||
parent_comment_id: body.parent_comment_id || null,
|
||||
is_ai_seeded: false,
|
||||
author_username: prof?.username || null,
|
||||
author_display_name: prof?.display_name || user.email?.split("@")[0] || "Member",
|
||||
author_avatar_url: prof?.avatar_url || null,
|
||||
};
|
||||
const { data, error } = await sb
|
||||
.from("article_comments")
|
||||
.insert(ins)
|
||||
.select()
|
||||
.single();
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ comment: data });
|
||||
}
|
||||
119
src/app/api/upload/image/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* POST /api/upload/image
|
||||
*
|
||||
* Universal image upload endpoint. Every surface (forum composer, PR
|
||||
* submission, contributor article, featured-story admin, avatar update,
|
||||
* banner upload, anything else) POSTs here.
|
||||
*
|
||||
* Auth: requires a logged-in Supabase user. Anonymous uploads are
|
||||
* rejected to keep the bucket from being abused as a public file dump.
|
||||
*
|
||||
* Pipeline (see lib/images/optimize.ts):
|
||||
* - sharp resize to ≤4096px longest side
|
||||
* - AVIF + WebP + format-preserving fallback (JPEG/PNG) at q=82
|
||||
* - 320px thumbnail
|
||||
*
|
||||
* Storage: writes to the `bb-media` Supabase bucket under
|
||||
* /<user_id>/<yyyy>/<mm>/<random>.{webp,avif,jpg,png}
|
||||
* and returns the public URLs + dimensions so the client can either
|
||||
* embed an <img srcset> or paste a markdown  into their post.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createClient as createServer } from "@/lib/supabase/server";
|
||||
import { createClient as createService } from "@supabase/supabase-js";
|
||||
import { optimizeImage } from "@/lib/images/optimize";
|
||||
|
||||
const BUCKET = "bb-media";
|
||||
const MAX_INPUT_BYTES = 20 * 1024 * 1024; // 20 MB hard cap on input
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// ── 1. Auth ──────────────────────────────────────────────────────────────
|
||||
const sb = await createServer();
|
||||
const { data: { user } } = await sb.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── 2. Read upload ───────────────────────────────────────────────────────
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
||||
}
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_INPUT_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 20 MB.` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "Not an image" }, { status: 415 });
|
||||
}
|
||||
|
||||
const input = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// ── 3. Optimize ──────────────────────────────────────────────────────────
|
||||
let opt;
|
||||
try {
|
||||
opt = await optimizeImage(input);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "image decode failed";
|
||||
return NextResponse.json({ error: `Could not process image: ${msg}` }, { status: 422 });
|
||||
}
|
||||
|
||||
// ── 4. Upload derivatives ────────────────────────────────────────────────
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || "";
|
||||
if (!url || !key) {
|
||||
return NextResponse.json({ error: "Storage not configured" }, { status: 500 });
|
||||
}
|
||||
const svc = createService(url, key, { auth: { persistSession: false } });
|
||||
|
||||
const now = new Date();
|
||||
const dir = `${user.id}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
const stub = randomBytes(8).toString("hex");
|
||||
|
||||
async function upload(suffix: string, body: Buffer, contentType: string): Promise<string | null> {
|
||||
const path = `${dir}/${stub}${suffix}`;
|
||||
const { error } = await svc.storage.from(BUCKET).upload(path, body, {
|
||||
contentType, upsert: false, cacheControl: "31536000, immutable",
|
||||
});
|
||||
if (error) {
|
||||
console.error("storage upload failed", path, error);
|
||||
return null;
|
||||
}
|
||||
return svc.storage.from(BUCKET).getPublicUrl(path).data.publicUrl;
|
||||
}
|
||||
|
||||
const urls: Record<string, string | null> = {};
|
||||
if (opt.avif) urls.avif = await upload(".avif", opt.avif, "image/avif");
|
||||
/* webp is mandatory */ urls.webp = await upload(".webp", opt.webp, "image/webp");
|
||||
if (opt.jpeg) urls.jpeg = await upload(".jpg", opt.jpeg, "image/jpeg");
|
||||
if (opt.png) urls.png = await upload(".png", opt.png, "image/png");
|
||||
if (opt.thumbnail) urls.thumbnail = await upload("_thumb.jpg", opt.thumbnail, "image/jpeg");
|
||||
|
||||
if (!urls.webp) {
|
||||
return NextResponse.json({ error: "Storage upload failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
// ── 5. Return URLs + metadata ───────────────────────────────────────────
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
width: opt.width,
|
||||
height: opt.height,
|
||||
format: opt.format,
|
||||
hasAlpha: opt.hasAlpha,
|
||||
urls,
|
||||
bytes: opt.bytes,
|
||||
// Convenience: the most-compatible URL the caller should embed by default.
|
||||
// Browsers that support AVIF will pick it via <picture>; this URL is the
|
||||
// safe-default <img src=>.
|
||||
primaryUrl: urls.webp,
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
import StarRating from "@/components/StarRating";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
import type { RelatedForumThread } from "@/lib/articles/legacy-source";
|
||||
import ArticleComments from "@/components/ArticleComments";
|
||||
|
||||
// DO NOT OVERRIDE — ArticleDetailClient interface and session tracking
|
||||
interface ArticleDetailClientProps {
|
||||
article: Article;
|
||||
relatedArticles: Article[];
|
||||
relatedForumThreads?: RelatedForumThread[];
|
||||
}
|
||||
|
||||
function getSessionId(): string {
|
||||
@@ -25,7 +28,7 @@ function getSessionId(): string {
|
||||
return sid;
|
||||
}
|
||||
|
||||
export default function ArticleDetailClient({ article, relatedArticles }: ArticleDetailClientProps) {
|
||||
export default function ArticleDetailClient({ article, relatedArticles, relatedForumThreads = [] }: ArticleDetailClientProps) {
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
|
||||
const [saveToast, setSaveToast] = useState<string | null>(null);
|
||||
@@ -226,9 +229,9 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
{/* DO NOT OVERRIDE — article breadcrumb and back navigation */}
|
||||
<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]">
|
||||
<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>/</span>
|
||||
<Link href={sectionHref} className="hover:text-[#3b82f6] transition-colors">{sectionLabel}</Link>
|
||||
<Link href={sectionHref} className="hover:text-[#E67E22] transition-colors">{sectionLabel}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-[#888] truncate max-w-[300px]">{article.title}</span>
|
||||
</div>
|
||||
@@ -242,7 +245,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={sectionHref} className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:underline">
|
||||
<Link href={sectionHref} className="text-xs font-bold uppercase tracking-widest text-[#E67E22] hover:underline">
|
||||
{article.category}
|
||||
</Link>
|
||||
<span className="text-xs text-[#777]">{article.date}</span>
|
||||
@@ -255,8 +258,6 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
<div className="mb-4">
|
||||
<StarRating seed={article.slug} publishedAt={article.publishedAt || article.date} size="md" />
|
||||
</div>
|
||||
<p className="font-body text-lg text-[#999] mb-6 leading-relaxed">{article.excerpt}</p>
|
||||
|
||||
{/* Article Meta */}
|
||||
<div className="flex items-center justify-between py-4 border-t border-b border-[#222]">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -270,7 +271,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
<div>
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
|
||||
@@ -280,12 +281,12 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
{(() => {
|
||||
const articleUrl = typeof window !== "undefined"
|
||||
? window.location.href
|
||||
: `https://broadcastbeat.com/articles/${article.slug}`;
|
||||
: `https://avbeat.com/articles/${article.slug}`;
|
||||
return (
|
||||
<>
|
||||
<a href={`mailto:?subject=${encodeURIComponent(article.title)}&body=${encodeURIComponent(articleUrl)}`}
|
||||
aria-label="Share via 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">
|
||||
<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" />
|
||||
@@ -296,7 +297,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] 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>
|
||||
</a>
|
||||
<a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=BroadcastBeat`}
|
||||
<a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=AVBeat`}
|
||||
target="_blank" rel="noopener noreferrer" aria-label="Share on X" title="X (Twitter)"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
@@ -319,7 +320,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
<button type="button"
|
||||
onClick={async () => { try { await navigator.clipboard.writeText(articleUrl); } catch {} }}
|
||||
aria-label="Copy article 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">
|
||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] 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">
|
||||
<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"/>
|
||||
@@ -333,7 +334,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
onClick={handleSaveToReadingList}
|
||||
disabled={savingState !== "idle"}
|
||||
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 === "removing" ? "Removing..." : isSaved ? "✓ Saved" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -367,7 +368,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
))}
|
||||
@@ -379,13 +380,17 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
<div className="pt-6 border-t border-[#222]">
|
||||
<Link
|
||||
href={sectionHref}
|
||||
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 width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
Back to {sectionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Article comments — anonymous read, signed-in members can
|
||||
post + vote + reply. AI personas participate too (tagged). */}
|
||||
<ArticleComments articleSlug={article.slug} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
@@ -393,7 +398,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
{/* Related Articles */}
|
||||
{/* DO NOT OVERRIDE — related articles sidebar */}
|
||||
<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
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
@@ -412,10 +417,10 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -424,6 +429,45 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Forum Posts — surfaces broadcast-pro discussion
|
||||
threads relevant to the same topic/keyword as the article.
|
||||
Title-keyword match, recency-ordered. Falls back to most-
|
||||
recently-active threads so the box is never empty. */}
|
||||
{relatedForumThreads.length > 0 && (
|
||||
<div className="bg-[#111] border border-[#222] p-5">
|
||||
<h3 className="font-body font-bold text-xs text-[#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>
|
||||
)}
|
||||
|
||||
{/* Sidebar ad stack — 300x600 + 300x250 rotating */}
|
||||
<SidebarAdStack />
|
||||
</aside>
|
||||
@@ -435,7 +479,7 @@ export default function ArticleDetailClient({ article, relatedArticles }: Articl
|
||||
<div
|
||||
role="status"
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getLegacyArticleBySlug,
|
||||
getLegacyRecentSlugs,
|
||||
getLegacyRelatedArticles,
|
||||
getRelatedForumThreads, type RelatedForumThread,
|
||||
} from "@/lib/articles/legacy-source";
|
||||
|
||||
export const revalidate = 3600;
|
||||
@@ -16,7 +17,7 @@ interface PageProps {
|
||||
}
|
||||
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://broadcastbeat.com";
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://avbeat.com";
|
||||
|
||||
function toIso(d: string | null | undefined): string {
|
||||
if (!d) return new Date().toISOString();
|
||||
@@ -33,11 +34,15 @@ function absoluteImage(src: string | undefined | null): string {
|
||||
async function loadArticle(slug: string): Promise<{
|
||||
article: Article | null;
|
||||
related: Article[];
|
||||
relatedForumThreads: RelatedForumThread[];
|
||||
}> {
|
||||
const imported = await getLegacyArticleBySlug(slug);
|
||||
if (!imported) return { article: null, related: [] };
|
||||
const related = await getLegacyRelatedArticles(slug, imported.category, 3);
|
||||
return { article: imported, related };
|
||||
if (!imported) return { article: null, related: [], relatedForumThreads: [] };
|
||||
const [related, relatedForumThreads] = await Promise.all([
|
||||
getLegacyRelatedArticles(slug, imported.category, 3),
|
||||
getRelatedForumThreads(imported.title, 6),
|
||||
]);
|
||||
return { article: imported, related, relatedForumThreads };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
@@ -65,7 +70,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
images: [article.image],
|
||||
creator: "@Broadcast Beat",
|
||||
creator: "@AV Beat",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -77,7 +82,7 @@ export async function generateStaticParams() {
|
||||
|
||||
export default async function ArticlePage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const { article, related } = await loadArticle(slug);
|
||||
const { article, related, relatedForumThreads } = await loadArticle(slug);
|
||||
if (!article) {
|
||||
// Slug isn't in any of our article tables — send the reader to the news
|
||||
// index so a click from the homepage/ticker doesn't dead-end on 404.
|
||||
@@ -93,7 +98,7 @@ export default async function ArticlePage({ params }: PageProps) {
|
||||
author: { "@type": "Person", name: article.author },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Broadcast Beat",
|
||||
name: "AV Beat",
|
||||
logo: { "@type": "ImageObject", url: `${SITE_URL}/assets/images/logo.png` },
|
||||
},
|
||||
datePublished: toIso(article.date),
|
||||
@@ -107,7 +112,7 @@ export default async function ArticlePage({ params }: PageProps) {
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<ArticleDetailPage article={article} relatedArticles={related} />
|
||||
<ArticleDetailPage article={article} relatedArticles={related} relatedForumThreads={relatedForumThreads} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function ClientLoginPage() {
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Broadcast Beat</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">AV Beat</h1>
|
||||
<p className="text-gray-600">Contributor Portal</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ const AV_CATEGORIES = [
|
||||
];
|
||||
|
||||
const SITE_CONFIG = {
|
||||
1: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]', borderClass: 'border-[#0ea5e9]/30' },
|
||||
1: { name: 'AV Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]', borderClass: 'border-[#0ea5e9]/30' },
|
||||
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]', borderClass: 'border-[#f97316]/30' },
|
||||
};
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function ContributorDashboard() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.scope === 'broadcast' && data.confidence > 0.75) {
|
||||
setScopeWarning('This content may be better suited for Broadcast Beat. AV Beat covers professional AV integration. Your post has been flagged for editorial review.');
|
||||
setScopeWarning('This content may be better suited for AV Beat. AV Beat covers professional AV integration. Your post has been flagged for editorial review.');
|
||||
} else {
|
||||
setScopeWarning(null);
|
||||
}
|
||||
@@ -194,7 +194,7 @@ export default function ContributorDashboard() {
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.blocked) {
|
||||
showNotification('error', 'This post could not be submitted. Please contact the editorial team at editor@broadcastbeat.com for assistance.');
|
||||
showNotification('error', 'This post could not be submitted. Please contact the editorial team at editor@avbeat.com for assistance.');
|
||||
} else {
|
||||
showNotification('error', data.error || 'Failed to submit post.');
|
||||
}
|
||||
@@ -361,7 +361,7 @@ export default function ContributorDashboard() {
|
||||
<div className="text-sm font-semibold text-white mb-3">Publish To</div>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ id: 1, name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9' },
|
||||
{ id: 1, name: 'AV Beat', badge: 'BB', color: '#0ea5e9' },
|
||||
{ id: 2, name: 'AV Beat', badge: 'AV', color: '#f97316' },
|
||||
].map(site => (
|
||||
<button
|
||||
|
||||
@@ -67,7 +67,7 @@ type TabType = 'calendar' | 'queue' | 'exhibitors' | 'style-profiles' | 'setting
|
||||
type SiteFilter = '' | '1' | '2';
|
||||
|
||||
const SITE_CONFIG = {
|
||||
1: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
|
||||
1: { name: 'AV Beat', badge: 'BB', color: '#0ea5e9', bgClass: 'bg-[#0ea5e9]/10', textClass: 'text-[#0ea5e9]' },
|
||||
2: { name: 'AV Beat', badge: 'AV', color: '#f97316', bgClass: 'bg-[#f97316]/10', textClass: 'text-[#f97316]' },
|
||||
};
|
||||
|
||||
@@ -256,7 +256,7 @@ export default function ShowCalendarPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-8 h-8 border-2 border-[#E67E22] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -289,12 +289,12 @@ export default function ShowCalendarPage() {
|
||||
<Link href="/admin" className="text-[#555] hover:text-white text-sm transition-colors">← Dashboard</Link>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white">Show Calendar Engine</h1>
|
||||
<p className="text-[#555] text-sm mt-0.5">Broadcast Beat + AV Beat — Global Show Coverage</p>
|
||||
<p className="text-[#555] text-sm mt-0.5">AV Beat + AV Beat — Global Show Coverage</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => { setEditingEvent({ site_id: 1, year: new Date().getFullYear(), seo_tier: 3, event_type: 'trade_show' }); setShowEventModal(true); }}
|
||||
className="px-3 py-1.5 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
||||
className="px-3 py-1.5 bg-[#E67E22] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
+ Add Event
|
||||
</button>
|
||||
@@ -316,11 +316,11 @@ export default function ShowCalendarPage() {
|
||||
onClick={() => setSiteFilter(s)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
siteFilter === s
|
||||
? 'bg-[#3b82f6] text-white'
|
||||
? 'bg-[#E67E22] text-white'
|
||||
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
|
||||
}`}
|
||||
>
|
||||
{s === '' ? 'All Sites' : s === '1' ? '🔵 Broadcast Beat' : '🟠 AV Beat'}
|
||||
{s === '' ? 'All Sites' : s === '1' ? '🔵 AV Beat' : '🟠 AV Beat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -333,7 +333,7 @@ export default function ShowCalendarPage() {
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab.id
|
||||
? 'border-[#3b82f6] text-white'
|
||||
? 'border-[#E67E22] text-white'
|
||||
: 'border-transparent text-[#666] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
@@ -347,7 +347,7 @@ export default function ShowCalendarPage() {
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{loadingData ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-8 h-8 border-2 border-[#E67E22] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -383,7 +383,7 @@ export default function ShowCalendarPage() {
|
||||
<span>Engine: {formatDate(event.story_engine_start_date)} – {formatDate(event.story_engine_end_date)}</span>
|
||||
{event.official_site && (
|
||||
<a href={`https://${event.official_site}`} target="_blank" rel="noopener noreferrer"
|
||||
className="text-[#3b82f6] hover:underline">{event.official_site}</a>
|
||||
className="text-[#E67E22] hover:underline">{event.official_site}</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-[#555]">
|
||||
@@ -440,7 +440,7 @@ export default function ShowCalendarPage() {
|
||||
onClick={() => setQueueStatus(s)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
queueStatus === s
|
||||
? 'bg-[#3b82f6] text-white'
|
||||
? 'bg-[#E67E22] text-white'
|
||||
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
|
||||
}`}
|
||||
>
|
||||
@@ -643,7 +643,7 @@ export default function ShowCalendarPage() {
|
||||
</div>
|
||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
||||
<p className="text-white font-medium mb-1">Pen Name Rosters</p>
|
||||
<p className="mb-2"><strong className="text-[#aaa]">Broadcast Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
|
||||
<p className="mb-2"><strong className="text-[#aaa]">AV Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
|
||||
<p><strong className="text-[#aaa]">AV Beat (7 writers):</strong> Rex Chandler, Dana Flux, Derek Wainwright, Sloane Rigging, Chip Crosspoint, Blair Presenter, Jordan Lumen</p>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
||||
@@ -689,7 +689,7 @@ export default function ShowCalendarPage() {
|
||||
onChange={e => setEditingEvent(prev => ({ ...prev, site_id: parseInt(e.target.value) }))}
|
||||
className="w-full bg-[#0a0a0a] border border-[#333] text-white text-sm rounded px-3 py-2"
|
||||
>
|
||||
<option value={1}>Broadcast Beat</option>
|
||||
<option value={1}>AV Beat</option>
|
||||
<option value={2}>AV Beat</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -779,7 +779,7 @@ export default function ShowCalendarPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={actionLoading === 'save-event'}
|
||||
className="flex-1 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex-1 py-2 bg-[#E67E22] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === 'save-event' ? 'Saving...' : 'Save Event'}
|
||||
</button>
|
||||
@@ -857,7 +857,7 @@ export default function ShowCalendarPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={actionLoading === 'generate'}
|
||||
className="flex-1 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex-1 py-2 bg-[#E67E22] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === 'generate' ? 'Generating...' : 'Generate Story'}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import ImageAttachButton from '@/components/forum/ImageAttachButton';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
interface ForumCategory {
|
||||
@@ -336,6 +337,10 @@ export default function ForumCategoryPage() {
|
||||
rows={5}
|
||||
className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6] resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<ImageAttachButton onInsert={(md) => setNewBody((b) => (b || "") + md)} />
|
||||
<span className="text-[10px] text-[#666]">Auto-optimized, never > 20 MB</span>
|
||||
</div>
|
||||
{newError && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{newError}
|
||||
|
||||
96
src/app/forum/forgot-password/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
function ForgotInner() {
|
||||
const supabase = createClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setMsg(null); setErr(null);
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), {
|
||||
redirectTo: `${window.location.origin}/forum/reset-password`,
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) {
|
||||
setErr(error.message);
|
||||
} else {
|
||||
setMsg("Check your inbox — we sent a password reset link. If you didn't have an account, no email is sent.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Account recovery</div>
|
||||
<h1 className="text-3xl font-heading font-bold text-white">Reset your password</h1>
|
||||
<p className="text-[#888] font-body text-sm mt-2">
|
||||
Enter your email — we’ll send a link to set a new password.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||
>
|
||||
{err && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className="bg-[#0a1f2a] border border-[#1e6c8c] text-[#7adbff] font-body text-sm px-3 py-2 rounded">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !email.trim()}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{busy ? "Sending…" : "Send reset link"}
|
||||
</button>
|
||||
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
|
||||
Remembered it?{" "}
|
||||
<Link href="/forum/login" className="text-[#3b82f6] hover:underline font-semibold">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ForgotInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'The Crew Lounge — Broadcast Beat',
|
||||
title: 'The Crew Lounge — AV Beat',
|
||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Discuss live production, IP workflows, streaming, audio, cameras, AI automation, and more.',
|
||||
alternates: { canonical: '/forum' },
|
||||
openGraph: {
|
||||
title: 'The Crew Lounge — Broadcast Beat',
|
||||
title: 'The Crew Lounge — AV Beat',
|
||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Ask questions, share expertise, and discuss broadcast technology.',
|
||||
url: '/forum',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title: 'The Crew Lounge — Broadcast Beat',
|
||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide on Broadcast Beat.',
|
||||
title: 'The Crew Lounge — AV Beat',
|
||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide on AV Beat.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,19 +21,19 @@ export default function ForumLayout({ children }: { children: React.ReactNode })
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Broadcast Beat — The Crew Lounge',
|
||||
name: 'AV Beat — The Crew Lounge',
|
||||
description: 'The Crew Lounge: an industry community for broadcast, motion picture and post production professionals worldwide.',
|
||||
url: 'https://broadcastbeat.com/forum',
|
||||
url: 'https://avbeat.com/forum',
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Broadcast Beat',
|
||||
url: 'https://broadcastbeat.com',
|
||||
name: 'AV Beat',
|
||||
url: 'https://avbeat.com',
|
||||
},
|
||||
breadcrumb: {
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://broadcastbeat.com' },
|
||||
{ '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://broadcastbeat.com/forum' },
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://avbeat.com' },
|
||||
{ '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://avbeat.com/forum' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
function safeNext(next: string | null): string {
|
||||
@@ -50,17 +51,18 @@ function ForumLoginInner() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-[#111111]">
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-heading font-bold text-white">Sign in to the Forum</h1>
|
||||
<p className="text-[#888] font-body text-sm mt-1">
|
||||
Use your Broadcast Beat account to post, reply, and vote.
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#E67E22] font-mono mb-2">Welcome back</div>
|
||||
<h1 className="text-3xl font-heading font-bold text-white">Sign in to AV Beat</h1>
|
||||
<p className="text-[#888] font-body text-sm mt-2">
|
||||
Your account covers the forum, newsletter preferences, and saved articles.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4"
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
@@ -77,7 +79,7 @@ function ForumLoginInner() {
|
||||
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]"
|
||||
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>
|
||||
@@ -90,21 +92,30 @@ function ForumLoginInner() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
|
||||
className="w-full bg-[#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'}
|
||||
</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">
|
||||
No account yet?{' '}
|
||||
<Link
|
||||
href={`/forum/register?next=${encodeURIComponent(next)}`}
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
|
||||
@@ -103,7 +103,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
||||
|
||||
const getMetricValue = (thread: ForumThread) => {
|
||||
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]' };
|
||||
};
|
||||
|
||||
@@ -123,7 +123,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
||||
onClick={() => setActiveMetric(m.value)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||
activeMetric === m.value
|
||||
? 'bg-[#3b82f6] text-white'
|
||||
? 'bg-[#E67E22] text-white'
|
||||
: 'text-[#666] hover:text-[#aaa]'
|
||||
}`}
|
||||
>
|
||||
@@ -171,13 +171,13 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
||||
</span>
|
||||
{/* Thread info */}
|
||||
<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}
|
||||
</p>
|
||||
<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 && (
|
||||
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
|
||||
<> · <span className="text-[#E67E22]">{thread.forum_categories.name}</span></>
|
||||
)}
|
||||
{' · '}{timeAgo(thread.created_at)}
|
||||
</p>
|
||||
@@ -301,7 +301,7 @@ export default function ForumIndexPage() {
|
||||
{currentUserId ? (
|
||||
<Link
|
||||
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">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
@@ -311,7 +311,7 @@ export default function ForumIndexPage() {
|
||||
) : (
|
||||
<Link
|
||||
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">
|
||||
<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
|
||||
onClick={() => setFeedMode('discover')}
|
||||
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">
|
||||
@@ -344,7 +344,7 @@ export default function ForumIndexPage() {
|
||||
<button
|
||||
onClick={() => setFeedMode('following')}
|
||||
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">
|
||||
@@ -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>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
@@ -384,7 +384,7 @@ export default function ForumIndexPage() {
|
||||
<Link
|
||||
href="/forum"
|
||||
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
|
||||
</Link>
|
||||
@@ -398,10 +398,10 @@ export default function ForumIndexPage() {
|
||||
<Link
|
||||
key={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">
|
||||
<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}
|
||||
</h3>
|
||||
<p className="text-[#666] font-body text-xs mt-0.5">
|
||||
@@ -409,7 +409,7 @@ export default function ForumIndexPage() {
|
||||
<Link
|
||||
href={`/profile/${thread.author_id}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline"
|
||||
>
|
||||
{thread.author_name}
|
||||
</Link>
|
||||
@@ -450,7 +450,7 @@ export default function ForumIndexPage() {
|
||||
placeholder="Search threads..."
|
||||
value={search}
|
||||
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 && (
|
||||
<button
|
||||
@@ -470,7 +470,7 @@ export default function ForumIndexPage() {
|
||||
<select
|
||||
value={selectedCategory}
|
||||
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>
|
||||
{categories.map(cat => (
|
||||
@@ -490,7 +490,7 @@ export default function ForumIndexPage() {
|
||||
onClick={() => setSort(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||
sort === opt.value
|
||||
? 'bg-[#3b82f6] text-white'
|
||||
? 'bg-[#E67E22] text-white'
|
||||
: 'text-[#888] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
@@ -523,16 +523,16 @@ export default function ForumIndexPage() {
|
||||
<Link
|
||||
key={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">
|
||||
<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}
|
||||
</h3>
|
||||
<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 && (
|
||||
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
|
||||
<> · <span className="text-[#E67E22]">{thread.forum_categories.name}</span></>
|
||||
)}
|
||||
{' · '}{timeAgo(thread.created_at)}
|
||||
</p>
|
||||
@@ -584,11 +584,11 @@ export default function ForumIndexPage() {
|
||||
<Link
|
||||
key={cat.id}
|
||||
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>
|
||||
<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}
|
||||
</h2>
|
||||
<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-[#666] font-body text-xs">posts</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" />
|
||||
</svg>
|
||||
</Link>
|
||||
@@ -629,7 +629,7 @@ export default function ForumIndexPage() {
|
||||
<ul className="text-[#aab4c4] font-body text-sm space-y-1">
|
||||
<li>• Be respectful and professional — this is a community of peers</li>
|
||||
<li>• Share real-world experience and cite sources when possible</li>
|
||||
<li>• The <span className="text-[#3b82f6]">Broadcast Beat AI</span> assistant is available in threads to help answer questions — it is clearly labeled</li>
|
||||
<li>• The <span className="text-[#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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import SocialAuthButtons from '@/components/auth/SocialAuthButtons';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
function safeNext(next: string | null): string {
|
||||
@@ -44,6 +45,34 @@ function ForumRegisterInner() {
|
||||
if (password !== confirm) return setError('Passwords do not match.');
|
||||
|
||||
setLoading(true);
|
||||
// Check first: if this email is already in our subscriber pool (existing
|
||||
// newsletter reader), we send them a magic-link login instead of creating
|
||||
// a new account — they already 'exist' to us. They can then set a
|
||||
// password from /account if they want.
|
||||
let knownSubscriber = false;
|
||||
try {
|
||||
const probe = await fetch(`/api/auth/lookup-email?email=${encodeURIComponent(email)}`);
|
||||
if (probe.ok) {
|
||||
const j = await probe.json();
|
||||
knownSubscriber = !!j.known;
|
||||
}
|
||||
} catch { /* probe failure → fall through to signUp */ }
|
||||
|
||||
if (knownSubscriber) {
|
||||
const { error: otpErr } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
|
||||
shouldCreateUser: true,
|
||||
data: { full_name: fullName.trim() },
|
||||
},
|
||||
});
|
||||
setLoading(false);
|
||||
if (otpErr) { setError(otpErr.message); return; }
|
||||
setInfo("Welcome back! You're already on our list. We sent a one-time login link to your inbox — click it to sign in. You can set a password from your account afterwards.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
@@ -72,18 +101,56 @@ function ForumRegisterInner() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-[#111111]">
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-heading font-bold text-white">Create a Forum Account</h1>
|
||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
||||
<div className="max-w-5xl mx-auto px-4 py-12 grid grid-cols-1 lg:grid-cols-[1fr_440px] gap-10 items-start">
|
||||
{/* Left rail — what membership gets you */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#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 & 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">
|
||||
Post threads, reply, vote, and follow the conversation.
|
||||
One account for the forum, newsletter preferences, and saved articles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4"
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||
>
|
||||
<div className="hidden lg:block mb-2">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#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 && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{error}
|
||||
@@ -104,7 +171,7 @@ function ForumRegisterInner() {
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
autoComplete="name"
|
||||
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>
|
||||
@@ -117,7 +184,7 @@ function ForumRegisterInner() {
|
||||
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]"
|
||||
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>
|
||||
@@ -131,7 +198,7 @@ function ForumRegisterInner() {
|
||||
autoComplete="new-password"
|
||||
required
|
||||
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>
|
||||
@@ -144,21 +211,29 @@ function ForumRegisterInner() {
|
||||
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 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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
|
||||
className="w-full bg-[#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>
|
||||
<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’ll never sell your email.
|
||||
</p>
|
||||
<SocialAuthButtons next={next} />
|
||||
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href={`/forum/login?next=${encodeURIComponent(next)}`}
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline font-semibold"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
|
||||
121
src/app/forum/reset-password/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
function ResetInner() {
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
const [hasSession, setHasSession] = useState<boolean | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// The recovery flow lands here with a fresh session (Supabase verifies
|
||||
// the token in the URL hash and signs the user in). Confirm we got it.
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
setHasSession(!!data.session);
|
||||
});
|
||||
}, [supabase]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setMsg(null); setErr(null);
|
||||
if (password.length < 8) { setErr("Use at least 8 characters."); setBusy(false); return; }
|
||||
if (password !== confirm) { setErr("Passwords don't match."); setBusy(false); return; }
|
||||
const { error } = await supabase.auth.updateUser({ password });
|
||||
setBusy(false);
|
||||
if (error) { setErr(error.message); return; }
|
||||
setMsg("Password updated. Redirecting…");
|
||||
setTimeout(() => router.push("/forum"), 1200);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
||||
<div className="max-w-md mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Reset password</div>
|
||||
<h1 className="text-3xl font-heading font-bold text-white">Set a new password</h1>
|
||||
</div>
|
||||
{hasSession === false ? (
|
||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 text-center">
|
||||
<p className="text-[#cbd5e1] mb-4 text-sm">
|
||||
This reset link is expired or invalid.
|
||||
</p>
|
||||
<Link
|
||||
href="/forum/forgot-password"
|
||||
className="inline-block bg-[#3b82f6] hover:bg-[#2563eb] text-white font-semibold text-sm px-5 py-2 rounded"
|
||||
>
|
||||
Request a new link
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||
>
|
||||
{err && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div className="bg-[#0a1f2a] border border-[#1e6c8c] text-[#7adbff] font-body text-sm px-3 py-2 rounded">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-body uppercase tracking-wider text-[#888] mb-1.5">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-semibold text-sm px-5 py-3 rounded-lg"
|
||||
>
|
||||
{busy ? "Saving…" : "Update password"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return <Suspense fallback={null}><ResetInner /></Suspense>;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { pickAds, rotateAll } from '@/lib/ads';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
import LinkifyPlainText from "@/components/LinkifyPlainText";
|
||||
import RichBody from "@/components/forum/RichBody";
|
||||
import ImageAttachButton from "@/components/forum/ImageAttachButton";
|
||||
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
||||
|
||||
// Inline ad slot inserted between forum replies every N posts.
|
||||
@@ -65,8 +67,8 @@ function timeAgo(dateStr: string): string {
|
||||
function Avatar({ name, isAI }: { name: string; isAI: boolean }) {
|
||||
if (isAI) {
|
||||
return (
|
||||
<div className="w-9 h-9 rounded-full bg-[#1e3a5f] border border-[#3b82f6] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[#3b82f6] text-xs font-bold">AI</span>
|
||||
<div className="w-9 h-9 rounded-full bg-[#1e3a5f] border border-[#E67E22] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[#E67E22] text-xs font-bold">AI</span>
|
||||
</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 (
|
||||
<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'}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-body font-semibold transition-colors border ${
|
||||
currentVote === 1
|
||||
? 'bg-[#3b82f6]/20 border-[#3b82f6] text-[#3b82f6]'
|
||||
: 'bg-transparent border-[#2a2a2a] text-[#666] hover:border-[#3b82f6] hover:text-[#3b82f6]'
|
||||
? 'bg-[#E67E22]/20 border-[#E67E22] text-[#E67E22]'
|
||||
: 'bg-transparent border-[#2a2a2a] text-[#666] hover:border-[#E67E22] hover:text-[#E67E22]'
|
||||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
aria-label="Upvote"
|
||||
>
|
||||
@@ -176,7 +178,7 @@ function VoteButtons({ targetType, targetId, upvotes, downvotes, voteScore, isAI
|
||||
</button>
|
||||
|
||||
{!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
|
||||
</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">
|
||||
{error || 'Thread not found.'}
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
@@ -340,11 +342,11 @@ export default function ForumThreadPage() {
|
||||
<div className="bg-[#1a2535] border-b border-[#2a3a50]">
|
||||
<div className="max-w-container mx-auto px-4 py-4">
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
@@ -364,14 +366,14 @@ export default function ForumThreadPage() {
|
||||
<Avatar name={thread.author_name} isAI={false} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">{timeAgo(thread.created_at)}</span>
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
<span className="text-[#555] font-body text-xs">{thread.view_count} views</span>
|
||||
</div>
|
||||
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
|
||||
<LinkifyPlainText body={thread.body} />
|
||||
<RichBody body={thread.body} />
|
||||
</p>
|
||||
<VoteButtons
|
||||
targetType="thread"
|
||||
@@ -401,9 +403,9 @@ export default function ForumThreadPage() {
|
||||
<Avatar name={reply.author_name} isAI={reply.is_ai_response} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 && (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
@@ -413,7 +415,7 @@ export default function ForumThreadPage() {
|
||||
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed">
|
||||
<LinkifyPlainText body={reply.body} />
|
||||
<RichBody body={reply.body} />
|
||||
</p>
|
||||
<VoteButtons
|
||||
targetType="reply"
|
||||
@@ -441,12 +443,12 @@ export default function ForumThreadPage() {
|
||||
{/* AI Assistant Panel */}
|
||||
<div className="bg-[#0d1f35] border border-[#1e3a5f] rounded-lg p-5 mb-6">
|
||||
<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">
|
||||
<span className="text-[#3b82f6] text-xs font-bold">AI</span>
|
||||
<div className="w-7 h-7 rounded-full bg-[#1e3a5f] border border-[#E67E22] flex items-center justify-center">
|
||||
<span className="text-[#E67E22] text-xs font-bold">AI</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white font-body font-semibold text-sm">Broadcast Beat AI Assistant</span>
|
||||
<span className="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="text-white font-body font-semibold text-sm">AV Beat AI Assistant</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>
|
||||
<p className="text-[#7a9ab8] font-body text-xs mb-3">
|
||||
@@ -459,12 +461,12 @@ export default function ForumThreadPage() {
|
||||
value={aiQuestion}
|
||||
onChange={e => setAiQuestion(e.target.value)}
|
||||
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
|
||||
type="submit"
|
||||
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 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -493,8 +495,12 @@ export default function ForumThreadPage() {
|
||||
onChange={e => setReplyBody(e.target.value)}
|
||||
required
|
||||
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 > 20 MB</span>
|
||||
</div>
|
||||
{replyError && (
|
||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||
{replyError}
|
||||
@@ -503,7 +509,7 @@ export default function ForumThreadPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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'}
|
||||
</button>
|
||||
@@ -520,13 +526,13 @@ export default function ForumThreadPage() {
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
@@ -12,11 +12,11 @@ import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Production Gear & Reviews — Broadcast Beat",
|
||||
title: "Production Gear & Reviews — AV Beat",
|
||||
description: "In-depth reviews and coverage of broadcast cameras, audio gear, production equipment, and professional media technology.",
|
||||
alternates: { canonical: "/gear" },
|
||||
openGraph: {
|
||||
title: "Production Gear & Reviews — Broadcast Beat",
|
||||
title: "Production Gear & Reviews — AV Beat",
|
||||
description: "In-depth reviews and coverage of broadcast cameras, audio gear, production equipment, and professional media technology.",
|
||||
url: "/gear",
|
||||
type: "website",
|
||||
@@ -49,7 +49,7 @@ export default async function GearPage() {
|
||||
<Link
|
||||
key={article.slug}
|
||||
href={`/articles/${article.slug}`}
|
||||
className="bg-[#111] border border-[#222] overflow-hidden group hover:border-[#3b82f6] transition-colors">
|
||||
className="bg-[#111] border border-[#222] overflow-hidden group hover:border-[#E67E22] transition-colors">
|
||||
<div className="relative h-[200px] overflow-hidden">
|
||||
<AppImage
|
||||
src={article.image}
|
||||
@@ -59,7 +59,7 @@ export default async function GearPage() {
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className="bg-[#3b82f6] text-white font-body text-[9px] font-bold px-2 py-0.5 tracking-wider uppercase">
|
||||
<span className="bg-[#E67E22] text-white font-body text-[9px] font-bold px-2 py-0.5 tracking-wider uppercase">
|
||||
{article.category}
|
||||
</span>
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@ export default async function GearPage() {
|
||||
)}
|
||||
<span className="text-[#555] font-body text-[11px]">{article.readTime}</span>
|
||||
</div>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-3">
|
||||
<h2 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug mb-2 group-hover:text-[#E67E22] transition-colors line-clamp-3">
|
||||
{article.title}
|
||||
</h2>
|
||||
<p className="text-[#666] font-body text-xs leading-relaxed line-clamp-2">{article.excerpt}</p>
|
||||
|
||||
@@ -16,13 +16,13 @@ export default function GlobalError({
|
||||
<body style={{ background: "#0d0d0d", color: "#e0e0e0", fontFamily: "system-ui", padding: 24, minHeight: "100vh" }}>
|
||||
<div style={{ maxWidth: 480, margin: "10vh auto", textAlign: "center" }}>
|
||||
<h2 style={{ fontSize: 22, marginBottom: 12 }}>Something went wrong</h2>
|
||||
<p style={{ marginBottom: 12, color: "#888" }}>Broadcast Beat hit an unexpected error.</p>
|
||||
<p style={{ marginBottom: 12, color: "#888" }}>AV Beat hit an unexpected error.</p>
|
||||
{error?.digest && (
|
||||
<p style={{ fontSize: 12, opacity: 0.5, marginBottom: 16, fontFamily: "monospace" }}>ref: {error.digest}</p>
|
||||
)}
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -314,7 +314,7 @@ export default function ArticleFeed() {
|
||||
value={sortOrder}
|
||||
onChange={(e) => { setSortOrder(e.target.value as SortOrder); setPage(1); setInfiniteCount(ARTICLES_PER_PAGE); }}
|
||||
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="trending">Trending</option>
|
||||
@@ -333,8 +333,8 @@ export default function ArticleFeed() {
|
||||
onClick={() => handleFeedModeChange("pagination")}
|
||||
aria-pressed={feedMode === "pagination"}
|
||||
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] ${
|
||||
feedMode === "pagination" ?"bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/40" :"text-[#666] hover:text-[#aaa] border border-transparent"
|
||||
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-[#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">
|
||||
<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")}
|
||||
aria-pressed={feedMode === "infinite"}
|
||||
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] ${
|
||||
feedMode === "infinite" ?"bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/40" :"text-[#666] hover:text-[#aaa] border border-transparent"
|
||||
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-[#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">
|
||||
<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}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
|
||||
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"
|
||||
aria-label="Filter articles from date"
|
||||
/>
|
||||
@@ -382,7 +382,7 @@ export default function ArticleFeed() {
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
|
||||
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"
|
||||
aria-label="Filter articles to date"
|
||||
/>
|
||||
@@ -401,9 +401,9 @@ export default function ArticleFeed() {
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
aria-expanded={showAdvanced}
|
||||
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
|
||||
? "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]"
|
||||
}`}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
@@ -411,7 +411,7 @@ export default function ArticleFeed() {
|
||||
</svg>
|
||||
More
|
||||
{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>
|
||||
</div>
|
||||
@@ -428,9 +428,9 @@ export default function ArticleFeed() {
|
||||
type="button"
|
||||
onClick={() => { setActiveCategory(cat); setPage(1); }}
|
||||
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
|
||||
? "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]"
|
||||
}`}>
|
||||
{cat}
|
||||
@@ -458,7 +458,7 @@ export default function ArticleFeed() {
|
||||
id={`${feedId}-author`}
|
||||
value={authorFilter}
|
||||
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">
|
||||
<option value="All">All Authors</option>
|
||||
{uniqueAuthors.map((author) => (
|
||||
@@ -483,10 +483,10 @@ export default function ArticleFeed() {
|
||||
key={sf}
|
||||
onClick={() => { setSourceFilter(sf); setPage(1); }}
|
||||
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
|
||||
? 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 === "Imported" && importedCount > 0 && (
|
||||
@@ -507,7 +507,7 @@ export default function ArticleFeed() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -537,7 +537,7 @@ export default function ArticleFeed() {
|
||||
{sortedFiltered.length === 0 && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
@@ -557,7 +557,7 @@ export default function ArticleFeed() {
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -585,7 +585,7 @@ export default function ArticleFeed() {
|
||||
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'
|
||||
: 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 (
|
||||
<Fragment key={`feed-${i}`}>
|
||||
@@ -593,7 +593,7 @@ export default function ArticleFeed() {
|
||||
<Link
|
||||
href={articleHref}
|
||||
{...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}
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -614,21 +614,21 @@ export default function ArticleFeed() {
|
||||
</span>
|
||||
)}
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</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
|
||||
href={articleHref}
|
||||
{...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}
|
||||
</Link>
|
||||
</h3>
|
||||
@@ -664,7 +664,7 @@ export default function ArticleFeed() {
|
||||
|
||||
{/* Inline social share row */}
|
||||
{(() => {
|
||||
const shareUrl = `https://broadcastbeat.com${articleHref.startsWith('/') ? articleHref : `/articles/${article?.slug}`}`;
|
||||
const shareUrl = `https://avbeat.com${articleHref.startsWith('/') ? articleHref : `/articles/${article?.slug}`}`;
|
||||
const shareTitle = article?.title || '';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-[#1a1a1a]">
|
||||
@@ -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">
|
||||
<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 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)"
|
||||
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">
|
||||
@@ -702,7 +702,7 @@ export default function ArticleFeed() {
|
||||
<a href={`mailto:?subject=${encodeURIComponent(shareTitle)}&body=${encodeURIComponent(shareUrl)}`}
|
||||
aria-label="Share via email" title="Email"
|
||||
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">
|
||||
<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>
|
||||
@@ -735,9 +735,9 @@ export default function ArticleFeed() {
|
||||
{isLoadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 py-6" aria-live="polite" aria-label="Loading more articles">
|
||||
<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-[#3b82f6] 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: "0ms" }} />
|
||||
<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-[#E67E22] animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
<span className="font-body text-xs text-[#555]">Loading more articles…</span>
|
||||
</div>
|
||||
@@ -762,7 +762,7 @@ export default function ArticleFeed() {
|
||||
</p>
|
||||
<div className="flex items-center gap-1" role="navigation" aria-label="Pagination">
|
||||
<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}
|
||||
onClick={() => { setPage(Math.max(1, page - 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
aria-label="Previous page">
|
||||
@@ -786,7 +786,7 @@ export default function ArticleFeed() {
|
||||
) : (
|
||||
<button
|
||||
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" }); }}
|
||||
aria-label={`Page ${p}`}
|
||||
aria-current={page === p ? "page" : undefined}>
|
||||
@@ -796,7 +796,7 @@ export default function ArticleFeed() {
|
||||
);
|
||||
})()}
|
||||
<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}
|
||||
onClick={() => { setPage(Math.min(totalPages, page + 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
aria-label="Next page">
|
||||
|
||||
@@ -13,14 +13,14 @@ import SponsorLogoStrip from "@/components/SponsorLogoStrip";
|
||||
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Broadcast Beat — Broadcast Engineering News & Insights',
|
||||
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
|
||||
title: 'AV Beat — Where AV Meets IT',
|
||||
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: {
|
||||
canonical: '/',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Broadcast Beat — Broadcast News',
|
||||
description: 'Breaking news and insights for broadcast engineering professionals.',
|
||||
title: 'AV Beat — Where AV Meets IT',
|
||||
description: 'Breaking news and insights at the intersection of pro AV and IT.',
|
||||
url: '/',
|
||||
type: 'website',
|
||||
images: [
|
||||
@@ -28,14 +28,14 @@ export const metadata: Metadata = {
|
||||
url: '/assets/images/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Broadcast Beat — Broadcast Engineering News & Insights',
|
||||
alt: 'AV Beat — Where AV Meets IT',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Broadcast Beat — Broadcast News',
|
||||
description: 'Breaking news and insights for broadcast engineering professionals.',
|
||||
title: 'AV Beat — Where AV Meets IT',
|
||||
description: 'Breaking news and insights at the intersection of pro AV and IT.',
|
||||
images: ['/assets/images/og-image.png'],
|
||||
},
|
||||
};
|
||||
@@ -138,7 +138,7 @@ export default function HomePage() {
|
||||
|
||||
{/* H1 Hero Heading */}
|
||||
<div className="sr-only">
|
||||
<h1>Broadcast Beat — Broadcast Engineering News & Insights</h1>
|
||||
<h1>AV Beat — Where AV Meets IT</h1>
|
||||
</div>
|
||||
|
||||
{/* Featured: cinematic hero + 4-up rail below (FeaturedBentoFromDb) */}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-static";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
/**
|
||||
* Image proxy: serves files from Supabase Storage under a clean broadcastbeat.com
|
||||
@@ -13,24 +14,59 @@ export const dynamic = "force-static";
|
||||
* /img/campaign-creatives/<uuid>/<file>.jpg
|
||||
* /img/bb-forum-avatars/<file>.png
|
||||
*
|
||||
* Path segments are forwarded directly to the matching public bucket.
|
||||
* Resilience rules (prior bug):
|
||||
* - Supabase storage occasionally returns transient 5xx. We retry up to 2x
|
||||
* with backoff before surfacing failure.
|
||||
* - Failures MUST emit no-store cache headers, otherwise Next + Cloudflare
|
||||
* pin the failure indefinitely (page itself had s-maxage of one year).
|
||||
* - The route must be dynamic — force-static used to pre-bake a single
|
||||
* fetch result at build time, which is exactly the bug we hit.
|
||||
*/
|
||||
const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com")
|
||||
.replace(/\/+$/, "") + "/storage/v1/object/public";
|
||||
|
||||
async function fetchWithRetry(url: string, tries = 3): Promise<Response> {
|
||||
let lastErr: unknown = null;
|
||||
for (let i = 0; i < tries; i++) {
|
||||
try {
|
||||
const r = await fetch(url, { cache: "no-store" });
|
||||
if (r.ok) return r;
|
||||
if (r.status === 404) return r;
|
||||
// 5xx — back off and retry
|
||||
lastErr = new Error(`upstream ${r.status}`);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
if (i < tries - 1) await new Promise((res) => setTimeout(res, 150 * (i + 1)));
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error("upstream failed");
|
||||
}
|
||||
|
||||
function failResponse(status: number, body: string) {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
// Critical: never cache failures.
|
||||
"cache-control": "no-store, max-age=0",
|
||||
"x-bb-img-source": "proxy-error",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params;
|
||||
if (!path || path.length < 2) return new Response("bad path", { status: 400 });
|
||||
if (!path || path.length < 2) return failResponse(400, "bad path");
|
||||
|
||||
const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`;
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, { cache: "force-cache" });
|
||||
upstream = await fetchWithRetry(target, 3);
|
||||
} catch (e: any) {
|
||||
return new Response("upstream error: " + (e?.message || String(e)), { status: 502 });
|
||||
return failResponse(502, "upstream error after retries: " + (e?.message || String(e)));
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
return new Response("not found", { status: upstream.status === 404 ? 404 : 502 });
|
||||
return failResponse(upstream.status === 404 ? 404 : 502, "not found");
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
|
||||
@@ -34,8 +34,8 @@ export const viewport: Viewport = {
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
||||
title: 'Broadcast Beat — Broadcast Engineering News & Insights',
|
||||
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
|
||||
title: 'AV Beat — Where AV Meets IT',
|
||||
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: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', type: 'image/x-icon', sizes: 'any' },
|
||||
@@ -58,25 +58,25 @@ export const metadata: Metadata = {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: '/',
|
||||
siteName: 'Broadcast Beat',
|
||||
title: 'Broadcast Beat — Broadcast News',
|
||||
description: 'Breaking news and insights for broadcast engineering professionals.',
|
||||
siteName: 'AV Beat',
|
||||
title: 'AV Beat — Where AV Meets IT',
|
||||
description: 'Breaking news and insights at the intersection of pro AV and IT.',
|
||||
images: [
|
||||
{
|
||||
url: '/assets/images/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Broadcast Beat — Broadcast Engineering News & Insights',
|
||||
alt: 'AV Beat — Where AV Meets IT',
|
||||
type: 'image/png'
|
||||
}]
|
||||
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@Broadcast Beat',
|
||||
creator: '@Broadcast Beat',
|
||||
title: 'Broadcast Beat — Broadcast News',
|
||||
description: 'Breaking news and insights for broadcast engineering professionals.',
|
||||
site: '@AVBeat',
|
||||
creator: '@AVBeat',
|
||||
title: 'AV Beat — Where AV Meets IT',
|
||||
description: 'Breaking news and insights at the intersection of pro AV and IT.',
|
||||
images: ['/assets/images/og-image.png']
|
||||
},
|
||||
robots: {
|
||||
@@ -122,10 +122,10 @@ export default function RootLayout({
|
||||
__html: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Broadcast Beat',
|
||||
url: 'https://broadcastbeat.com',
|
||||
logo: "https://broadcastbeat.com/assets/images/logo.png",
|
||||
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
|
||||
name: 'AV Beat',
|
||||
url: 'https://avbeat.com',
|
||||
logo: "https://avbeat.com/assets/images/logo.png",
|
||||
description: 'AV Beat covers the convergence of pro AV and IT — broadcast, live production, streaming, and post-production technology.',
|
||||
sameAs: [
|
||||
'https://linkedin.com',
|
||||
'https://twitter.com',
|
||||
@@ -135,7 +135,7 @@ export default function RootLayout({
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
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 */}
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
|
||||
@@ -53,7 +53,7 @@ function LoginInner() {
|
||||
<main className="min-h-screen bg-[#111111]">
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-heading font-bold text-white">Sign in to Broadcast Beat</h1>
|
||||
<h1 className="text-2xl font-heading font-bold text-white">Sign in to AV Beat</h1>
|
||||
<p className="text-[#888] font-body text-sm mt-1">
|
||||
Editor and admin access. Forum members can also sign in here.
|
||||
</p>
|
||||
@@ -77,7 +77,7 @@ function LoginInner() {
|
||||
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]"
|
||||
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>
|
||||
@@ -90,19 +90,19 @@ function LoginInner() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold py-2 rounded-lg transition-colors"
|
||||
className="w-full bg-[#E67E22] hover:bg-[#C8651B] disabled:opacity-50 text-white font-body font-semibold py-2 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<p className="text-xs text-[#666] font-body text-center pt-2">
|
||||
PR firm / contributor?{' '}
|
||||
<Link href="/client-login" className="text-[#3b82f6] hover:underline">
|
||||
<Link href="/client-login" className="text-[#E67E22] hover:underline">
|
||||
Use the contributor portal
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -105,13 +105,13 @@ export async function generateMetadata({
|
||||
const { slug } = await params;
|
||||
const result = await loadManufacturer(slug);
|
||||
if (!result) {
|
||||
return { title: "Manufacturer not found — Broadcast Beat" };
|
||||
return { title: "Manufacturer not found — AV Beat" };
|
||||
}
|
||||
const { company } = result;
|
||||
const title = `${company.company_name} — Manufacturer Profile | Broadcast Beat`;
|
||||
const title = `${company.company_name} — Manufacturer Profile | AV Beat`;
|
||||
const description =
|
||||
(company.bio || "").slice(0, 200) ||
|
||||
`${company.company_name} is a broadcast industry manufacturer covered by Broadcast Beat.`;
|
||||
`${company.company_name} is a broadcast industry manufacturer covered by AV Beat.`;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
@@ -119,7 +119,7 @@ export async function generateMetadata({
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `https://broadcastbeat.com/manufacturers/${slug}`,
|
||||
url: `https://avbeat.com/manufacturers/${slug}`,
|
||||
type: "profile",
|
||||
...(company.logo_url ? { images: [{ url: company.logo_url }] } : {}),
|
||||
},
|
||||
@@ -206,7 +206,7 @@ export default async function ManufacturerProfile({
|
||||
</span>
|
||||
)}
|
||||
{company.phone && (
|
||||
<a href={`tel:${company.phone.replace(/[^\d+]/g, '')}`} className="text-[#888] hover:text-[#3b82f6]">
|
||||
<a href={`tel:${company.phone.replace(/[^\d+]/g, '')}`} className="text-[#888] hover:text-[#E67E22]">
|
||||
{company.phone}
|
||||
</a>
|
||||
)}
|
||||
@@ -217,7 +217,7 @@ export default async function ManufacturerProfile({
|
||||
href={company.company_website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline"
|
||||
>
|
||||
Website ↗
|
||||
</a>
|
||||
@@ -227,7 +227,7 @@ export default async function ManufacturerProfile({
|
||||
href={company.press_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline"
|
||||
>
|
||||
Newsroom ↗
|
||||
</a>
|
||||
@@ -237,7 +237,7 @@ export default async function ManufacturerProfile({
|
||||
href={company.contact_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline"
|
||||
>
|
||||
Contact ↗
|
||||
</a>
|
||||
@@ -247,7 +247,7 @@ export default async function ManufacturerProfile({
|
||||
href={company.linkedin_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#888] hover:text-[#3b82f6]"
|
||||
className="text-[#888] hover:text-[#E67E22]"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
@@ -257,7 +257,7 @@ export default async function ManufacturerProfile({
|
||||
href={`https://twitter.com/${company.twitter_handle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#888] hover:text-[#3b82f6]"
|
||||
className="text-[#888] hover:text-[#E67E22]"
|
||||
>
|
||||
X / Twitter
|
||||
</a>
|
||||
@@ -306,7 +306,7 @@ export default async function ManufacturerProfile({
|
||||
href={e.linkedin_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-[#3b82f6] hover:underline"
|
||||
className="text-[11px] text-[#E67E22] hover:underline"
|
||||
>
|
||||
LinkedIn ↗
|
||||
</a>
|
||||
@@ -328,14 +328,14 @@ export default async function ManufacturerProfile({
|
||||
)}
|
||||
{company.phone && (
|
||||
<div className="mt-1">
|
||||
<a href={`tel:${company.phone.replace(/[^\d+]/g, '')}`} className="text-[#3b82f6] hover:underline">
|
||||
<a href={`tel:${company.phone.replace(/[^\d+]/g, '')}`} className="text-[#E67E22] hover:underline">
|
||||
{company.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{company.contact_email && (
|
||||
<div>
|
||||
<a href={`mailto:${company.contact_email}`} className="text-[#3b82f6] hover:underline">
|
||||
<a href={`mailto:${company.contact_email}`} className="text-[#E67E22] hover:underline">
|
||||
{company.contact_email}
|
||||
</a>
|
||||
</div>
|
||||
@@ -401,7 +401,7 @@ export default async function ManufacturerProfile({
|
||||
<section className="mb-10">
|
||||
<header className="flex items-baseline justify-between mb-3">
|
||||
<h2 className="text-lg font-display font-bold text-[#e0e0e0]">Recent coverage</h2>
|
||||
<Link href={`/news?search=${encodeURIComponent(company.company_name)}`} className="text-xs text-[#3b82f6] hover:underline">
|
||||
<Link href={`/news?search=${encodeURIComponent(company.company_name)}`} className="text-xs text-[#E67E22] hover:underline">
|
||||
All stories →
|
||||
</Link>
|
||||
</header>
|
||||
@@ -410,7 +410,7 @@ export default async function ManufacturerProfile({
|
||||
<Link
|
||||
key={r.wp_slug}
|
||||
href={`/news/${r.wp_slug}`}
|
||||
className="group bg-[#0d0d0d] border border-[#252525] rounded-md p-3 hover:border-[#3b82f6] hover:shadow-sm transition-all flex gap-3"
|
||||
className="group bg-[#0d0d0d] border border-[#252525] rounded-md p-3 hover:border-[#E67E22] hover:shadow-sm transition-all flex gap-3"
|
||||
>
|
||||
{/* Image fallback cascade — Ryan rule: never show an empty
|
||||
gray box on a coverage card. Falls through:
|
||||
@@ -423,7 +423,7 @@ export default async function ManufacturerProfile({
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xs font-semibold text-[#e0e0e0] leading-snug group-hover:text-[#3b82f6] line-clamp-3">
|
||||
<h3 className="text-xs font-semibold text-[#e0e0e0] leading-snug group-hover:text-[#E67E22] line-clamp-3">
|
||||
{r.title}
|
||||
</h3>
|
||||
{r.wp_published_at && (
|
||||
@@ -447,7 +447,7 @@ export default async function ManufacturerProfile({
|
||||
{p.slug ? (
|
||||
<Link
|
||||
href={`/news/${p.slug}`}
|
||||
className="text-[#3b82f6] hover:underline"
|
||||
className="text-[#E67E22] hover:underline"
|
||||
>
|
||||
{p.title}
|
||||
</Link>
|
||||
@@ -466,7 +466,7 @@ export default async function ManufacturerProfile({
|
||||
)}
|
||||
|
||||
<footer className="text-xs text-[#666] mt-12 pt-6 border-t border-[#252525]">
|
||||
Listing maintained by Broadcast Beat editorial.
|
||||
Listing maintained by AV Beat editorial.
|
||||
{company.last_mentioned && (
|
||||
<> Last mentioned in coverage on {new Date(company.last_mentioned).toISOString().slice(0, 10)}.</>
|
||||
)}
|
||||
|
||||
@@ -8,16 +8,16 @@ import ManufacturerDirectoryClient from "./DirectoryClient";
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Production Industry Manufacturers — Broadcast Beat",
|
||||
title: "Production Industry Manufacturers — AV Beat",
|
||||
description:
|
||||
"Browse 1,000+ broadcast, post-production, and live-production manufacturers exhibiting at NAB Show and IBC. Find vendor profiles, booth numbers, product categories, and the latest news from each company.",
|
||||
alternates: { canonical: "/manufacturers" },
|
||||
openGraph: {
|
||||
title: "Production Industry Manufacturers — Broadcast Beat",
|
||||
title: "Production Industry Manufacturers — AV Beat",
|
||||
description:
|
||||
"Comprehensive directory of broadcast industry manufacturers, exhibitors, and vendors.",
|
||||
type: "website",
|
||||
url: "https://broadcastbeat.com/manufacturers",
|
||||
url: "https://avbeat.com/manufacturers",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,12 +97,12 @@ export default async function ManufacturersIndex() {
|
||||
<section className="mt-16 border-t border-[#252525] pt-8 text-sm text-[#888] leading-relaxed">
|
||||
<p>
|
||||
Sources: 2026 NAB Show exhibitor list (Map Your Show), IBC exhibitor list,
|
||||
manufacturer-direct press releases, and reporting tracked on Broadcast Beat.
|
||||
manufacturer-direct press releases, and reporting tracked on AV Beat.
|
||||
New manufacturers and updates land here as our crawlers detect them.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Are you a manufacturer and want to update your listing?{" "}
|
||||
<a href="mailto:editors@broadcastbeat.com?subject=Manufacturer%20Directory%20Update" className="text-[#3b82f6] hover:underline">
|
||||
<a href="mailto:editors@avbeat.com?subject=Manufacturer%20Directory%20Update" className="text-[#E67E22] hover:underline">
|
||||
Contact our editors
|
||||
</a>
|
||||
.
|
||||
|
||||
@@ -3,7 +3,7 @@ import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Billing | Broadcast Beat Marketplace',
|
||||
title: 'Billing | AV Beat Marketplace',
|
||||
};
|
||||
|
||||
export default function BillingPage() {
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function GigsPage() {
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-4xl mb-4">🎬</div>
|
||||
<p className="text-lg font-semibold mb-2">No gigs posted yet</p>
|
||||
<p className="text-sm">Be the first to post a crew call on Broadcast Beat Marketplace.</p>
|
||||
<p className="text-sm">Be the first to post a crew call on AV Beat Marketplace.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function JobsPage() {
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<div className="text-4xl mb-4">📋</div>
|
||||
<p className="text-lg font-semibold mb-2">No jobs posted yet</p>
|
||||
<p className="text-sm">Be the first to post a job on Broadcast Beat Marketplace.</p>
|
||||
<p className="text-sm">Be the first to post a job on AV Beat Marketplace.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -3,7 +3,7 @@ import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Production Crew & Vendor Marketplace | Broadcast Beat',
|
||||
title: 'Production Crew & Vendor Marketplace | AV Beat',
|
||||
description:
|
||||
'Find broadcast and production crew, vendors, jobs, and gig opportunities. The professional marketplace built for the broadcast industry.',
|
||||
};
|
||||
|
||||
@@ -9,16 +9,16 @@ import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
||||
export const revalidate = 600;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "2026 NAB Show — Live Coverage Hub | Broadcast Beat",
|
||||
title: "2026 NAB Show — Live Coverage Hub | AV Beat",
|
||||
description:
|
||||
"Broadcast Beat's live coverage of 2026 NAB Show: 1,100+ exhibitors, the latest product launches, booth-by-booth news, and editorial pick stories from the show floor.",
|
||||
"AV Beat's live coverage of 2026 NAB Show: 1,100+ exhibitors, the latest product launches, booth-by-booth news, and editorial pick stories from the show floor.",
|
||||
alternates: { canonical: "/nab-2026" },
|
||||
openGraph: {
|
||||
title: "2026 NAB Show — Live Coverage Hub",
|
||||
description:
|
||||
"Live coverage, exhibitor list, and breaking product news from 2026 NAB Show.",
|
||||
type: "website",
|
||||
url: "https://broadcastbeat.com/nab-2026",
|
||||
url: "https://avbeat.com/nab-2026",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ export default async function NabHub() {
|
||||
</h1>
|
||||
<p className="text-[#aaa] text-base max-w-3xl">
|
||||
{exhibitors.length.toLocaleString()} exhibitors confirmed.
|
||||
{sponsorRows.length > 0 && <> {sponsorRows.length} are Broadcast Beat content partners.</>}
|
||||
{sponsorRows.length > 0 && <> {sponsorRows.length} are AV Beat content partners.</>}
|
||||
{" "}Browse the directory, read the latest product news, and find every booth.
|
||||
</p>
|
||||
</div>
|
||||
@@ -153,7 +153,7 @@ export default async function NabHub() {
|
||||
<h2 className="text-xl font-display font-bold text-[#e0e0e0]">
|
||||
Latest from the show floor
|
||||
</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 →
|
||||
</Link>
|
||||
</header>
|
||||
@@ -162,7 +162,7 @@ export default async function NabHub() {
|
||||
<Link
|
||||
key={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 && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
@@ -174,7 +174,7 @@ export default async function NabHub() {
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
</h3>
|
||||
{a.wp_published_at && (
|
||||
@@ -195,7 +195,7 @@ export default async function NabHub() {
|
||||
<h2 className="text-xl font-display font-bold text-[#e0e0e0]">
|
||||
All 2026 NAB Show exhibitors
|
||||
</h2>
|
||||
<Link href="/manufacturers" className="text-xs text-[#3b82f6] hover:underline">
|
||||
<Link href="/manufacturers" className="text-xs text-[#E67E22] hover:underline">
|
||||
Full directory →
|
||||
</Link>
|
||||
</header>
|
||||
@@ -205,7 +205,7 @@ export default async function NabHub() {
|
||||
key={e.slug}
|
||||
href={`/manufacturers/${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 ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
@@ -222,7 +222,7 @@ export default async function NabHub() {
|
||||
{otherRows.length > 200 && (
|
||||
<p className="text-center text-xs text-[#888] mt-4">
|
||||
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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -6,9 +6,9 @@ export const revalidate = 600; // 10 min — Google rechecks frequently
|
||||
// Google News sitemap. Only original-editorial articles (ai_rewritten_at IS
|
||||
// NOT NULL or rewritten-articles surface) from the last 48 hours so we
|
||||
// stay inside the GN window. Standard sitemap.xml covers older pages.
|
||||
const PUBLICATION_NAME = 'Broadcast Beat';
|
||||
const PUBLICATION_NAME = 'AV Beat';
|
||||
const PUBLICATION_LANG = 'en';
|
||||
const HOST = 'https://broadcastbeat.com';
|
||||
const HOST = 'https://avbeat.com';
|
||||
|
||||
function esc(s: string): string {
|
||||
return (s || '').replace(/[&<>"']/g, (c) =>
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function NewsPage({
|
||||
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e] py-4">
|
||||
<div className="max-w-container mx-auto px-4 space-y-3">
|
||||
{/* 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 */}
|
||||
<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">
|
||||
@@ -175,7 +175,7 @@ export default function NewsPage({
|
||||
placeholder="Search articles, topics, authors…"
|
||||
value={search}
|
||||
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 && (
|
||||
<button
|
||||
@@ -196,7 +196,7 @@ export default function NewsPage({
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
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"
|
||||
/>
|
||||
<span className="text-[#444] text-xs">–</span>
|
||||
@@ -204,7 +204,7 @@ export default function NewsPage({
|
||||
type="date"
|
||||
value={dateTo}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export default function NewsPage({
|
||||
<select
|
||||
value={sortBy}
|
||||
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) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
@@ -236,8 +236,8 @@ export default function NewsPage({
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${
|
||||
activeCategory === cat
|
||||
? "bg-[#3b82f6] border-[#3b82f6] text-white"
|
||||
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]"
|
||||
? "bg-[#E67E22] border-[#E67E22] text-white"
|
||||
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]"
|
||||
}`}
|
||||
>
|
||||
{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" />
|
||||
</svg>
|
||||
<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 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">
|
||||
<AppImage
|
||||
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
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="160px"
|
||||
@@ -297,14 +297,14 @@ export default function NewsPage({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
<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"}
|
||||
</h2>
|
||||
<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">
|
||||
<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-[#555] font-body text-xs">{article.readTime || ""}</span>
|
||||
</div>
|
||||
@@ -326,7 +326,7 @@ export default function NewsPage({
|
||||
{!hideAiSuggestions && <AISuggestedArticles variant="full" />}
|
||||
|
||||
<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
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -336,8 +336,8 @@ export default function NewsPage({
|
||||
onClick={() => setActiveCategory(tag)}
|
||||
className={`px-2 py-1 border text-xs font-body rounded-sm transition-colors ${
|
||||
activeCategory === tag
|
||||
? "bg-[#3b82f6] border-[#3b82f6] text-white"
|
||||
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]"
|
||||
? "bg-[#E67E22] border-[#E67E22] text-white"
|
||||
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
@@ -346,7 +346,7 @@ export default function NewsPage({
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<Link href="/home-page#newsletter" className="btn-subscribe text-xs py-2 px-4 inline-block">Subscribe Free</Link>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,13 @@ import ArticleBody from "@/components/ArticleBody";
|
||||
import StarRating from "@/components/StarRating";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
import type { RelatedForumThread } from "@/lib/articles/legacy-source";
|
||||
import ArticleComments from "@/components/ArticleComments";
|
||||
|
||||
interface NewsArticleDetailClientProps {
|
||||
article: Article;
|
||||
relatedArticles: Article[];
|
||||
relatedForumThreads?: RelatedForumThread[];
|
||||
companiesInStory?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ function getSessionId(): string {
|
||||
export default function NewsArticleDetailClient({
|
||||
article,
|
||||
relatedArticles,
|
||||
relatedForumThreads = [],
|
||||
companiesInStory,
|
||||
}: NewsArticleDetailClientProps) {
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
@@ -229,7 +233,7 @@ export default function NewsArticleDetailClient({
|
||||
setEmailSending(true);
|
||||
setEmailStatus("idle");
|
||||
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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -265,7 +269,7 @@ export default function NewsArticleDetailClient({
|
||||
const articleUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.href
|
||||
: `https://broadcastbeat.com/news/${article.slug}`;
|
||||
: `https://avbeat.com/news/${article.slug}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -274,11 +278,11 @@ export default function NewsArticleDetailClient({
|
||||
{/* Breadcrumb */}
|
||||
<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]">
|
||||
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors">
|
||||
<Link href="/home-page" className="hover:text-[#E67E22] transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href="/news" className="hover:text-[#3b82f6] transition-colors">
|
||||
<Link href="/news" className="hover:text-[#E67E22] transition-colors">
|
||||
News
|
||||
</Link>
|
||||
<span>/</span>
|
||||
@@ -296,7 +300,7 @@ export default function NewsArticleDetailClient({
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
<span className="text-xs text-[#777]">{article.readTime}</span>
|
||||
@@ -325,7 +329,7 @@ export default function NewsArticleDetailClient({
|
||||
<div>
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
|
||||
@@ -339,7 +343,7 @@ export default function NewsArticleDetailClient({
|
||||
onClick={() => setEmailShareOpen(true)}
|
||||
aria-label="Share via 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">
|
||||
<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" />
|
||||
@@ -360,7 +364,7 @@ export default function NewsArticleDetailClient({
|
||||
|
||||
{/* X / Twitter */}
|
||||
<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"
|
||||
aria-label="Share on X"
|
||||
title="X (Twitter)"
|
||||
@@ -414,7 +418,7 @@ export default function NewsArticleDetailClient({
|
||||
}}
|
||||
aria-label="Copy article 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">
|
||||
<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"/>
|
||||
@@ -428,7 +432,7 @@ export default function NewsArticleDetailClient({
|
||||
onClick={handleSaveToReadingList}
|
||||
disabled={savingState !== "idle"}
|
||||
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 === "removing" ?"Removing..."
|
||||
: isSaved
|
||||
@@ -465,7 +469,7 @@ export default function NewsArticleDetailClient({
|
||||
{article.tags.map((tag) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
))}
|
||||
@@ -479,7 +483,7 @@ export default function NewsArticleDetailClient({
|
||||
<p className="font-body text-sm text-[#777]">Found this article useful?</p>
|
||||
<button
|
||||
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
|
||||
width="13"
|
||||
height="13"
|
||||
@@ -500,7 +504,7 @@ export default function NewsArticleDetailClient({
|
||||
<div className="pt-6 border-t border-[#222]">
|
||||
<Link
|
||||
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
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -514,6 +518,10 @@ export default function NewsArticleDetailClient({
|
||||
Back to News
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Article comments — sign-in gated for posting + voting,
|
||||
public for reading. AI personas participate (tagged). */}
|
||||
<ArticleComments articleSlug={article.slug} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
@@ -526,7 +534,7 @@ export default function NewsArticleDetailClient({
|
||||
|
||||
{/* Related Articles Sidebar */}
|
||||
<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
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
@@ -545,10 +553,10 @@ export default function NewsArticleDetailClient({
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -557,6 +565,42 @@ export default function NewsArticleDetailClient({
|
||||
</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 */}
|
||||
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
|
||||
<h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3>
|
||||
@@ -595,7 +639,7 @@ export default function NewsArticleDetailClient({
|
||||
onClick={prevCarousel}
|
||||
disabled={carouselIndex === 0}
|
||||
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
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -611,7 +655,7 @@ export default function NewsArticleDetailClient({
|
||||
onClick={nextCarousel}
|
||||
disabled={carouselIndex >= maxIndex}
|
||||
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
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -650,11 +694,11 @@ export default function NewsArticleDetailClient({
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
</span>
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-[#666] font-body text-xs line-clamp-2 leading-relaxed">
|
||||
@@ -679,7 +723,7 @@ export default function NewsArticleDetailClient({
|
||||
onClick={() => setCarouselIndex(i)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
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");
|
||||
}}
|
||||
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
|
||||
width="18"
|
||||
height="18"
|
||||
@@ -733,7 +777,7 @@ export default function NewsArticleDetailClient({
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke="#E67E22"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
@@ -779,7 +823,7 @@ export default function NewsArticleDetailClient({
|
||||
<label
|
||||
htmlFor="email-to"
|
||||
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>
|
||||
<input
|
||||
id="email-to"
|
||||
@@ -787,7 +831,7 @@ export default function NewsArticleDetailClient({
|
||||
value={emailTo}
|
||||
onChange={(e) => setEmailTo(e.target.value)}
|
||||
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>
|
||||
@@ -802,7 +846,7 @@ export default function NewsArticleDetailClient({
|
||||
onChange={(e) => setEmailNote(e.target.value)}
|
||||
placeholder="Thought you might find this interesting..."
|
||||
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>
|
||||
|
||||
@@ -846,7 +890,7 @@ export default function NewsArticleDetailClient({
|
||||
<div
|
||||
role="status"
|
||||
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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getLegacyArticleBySlug,
|
||||
getLegacyRecentSlugs,
|
||||
getLegacyRelatedArticles,
|
||||
getRelatedForumThreads, type RelatedForumThread,
|
||||
} from "@/lib/articles/legacy-source";
|
||||
import { linkifyAndExtractMentions } from "@/lib/company-mentions";
|
||||
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
||||
@@ -21,7 +22,7 @@ interface PageProps {
|
||||
}
|
||||
|
||||
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. */
|
||||
function toIso(d: string | null | undefined): string {
|
||||
@@ -40,11 +41,15 @@ function absoluteImage(src: string | undefined | null): string {
|
||||
async function loadArticle(slug: string): Promise<{
|
||||
article: Article | null;
|
||||
related: Article[];
|
||||
relatedForumThreads: RelatedForumThread[];
|
||||
}> {
|
||||
const imported = await getLegacyArticleBySlug(slug);
|
||||
if (!imported) return { article: null, related: [] };
|
||||
const related = await getLegacyRelatedArticles(slug, imported.category, 6);
|
||||
return { article: imported, related };
|
||||
if (!imported) return { article: null, related: [], relatedForumThreads: [] };
|
||||
const [related, relatedForumThreads] = await Promise.all([
|
||||
getLegacyRelatedArticles(slug, imported.category, 6),
|
||||
getRelatedForumThreads(imported.title, 6),
|
||||
]);
|
||||
return { article: imported, related, relatedForumThreads };
|
||||
}
|
||||
|
||||
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.title} — Broadcast Beat`,
|
||||
title: `${article.title} — AV Beat`,
|
||||
description: article.excerpt,
|
||||
alternates: { canonical: `/news/${article.slug}` },
|
||||
openGraph: {
|
||||
@@ -72,7 +77,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
images: [article.image],
|
||||
creator: "@Broadcast Beat",
|
||||
creator: "@AV Beat",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -86,7 +91,7 @@ export async function generateStaticParams() {
|
||||
|
||||
export default async function NewsArticlePage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const { article, related } = await loadArticle(slug);
|
||||
const { article, related, relatedForumThreads } = await loadArticle(slug);
|
||||
if (!article) {
|
||||
// Unknown slug — fall back to the news index instead of 404.
|
||||
redirect("/news");
|
||||
@@ -106,7 +111,7 @@ export default async function NewsArticlePage({ params }: PageProps) {
|
||||
author: { "@type": "Person", name: article.author },
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "Broadcast Beat",
|
||||
name: "AV Beat",
|
||||
logo: { "@type": "ImageObject", url: `${SITE_URL}/assets/images/logo.png` },
|
||||
},
|
||||
datePublished: toIso(article.date),
|
||||
@@ -123,6 +128,7 @@ export default async function NewsArticlePage({ params }: PageProps) {
|
||||
<NewsArticleDetailClient
|
||||
article={articleWithLinks}
|
||||
relatedArticles={related}
|
||||
relatedForumThreads={relatedForumThreads}
|
||||
companiesInStory={
|
||||
mentionedSlugs.length > 0 ? (
|
||||
<CompaniesInThisStory slugs={mentionedSlugs} />
|
||||
|
||||
@@ -6,9 +6,9 @@ import { getLegacyFeaturedArticles } from "@/lib/articles/legacy-source";
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Featured Stories — Broadcast Beat",
|
||||
title: "Featured Stories — AV Beat",
|
||||
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" },
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function FeaturedNewsPage() {
|
||||
articles={articles}
|
||||
kicker="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
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Industry News — Broadcast Beat",
|
||||
title: "Industry News — AV Beat",
|
||||
description:
|
||||
"Breaking news and industry updates from the world of broadcast engineering, IP/cloud workflows, streaming, AI, and live production.",
|
||||
alternates: { canonical: "/news" },
|
||||
|
||||
@@ -63,7 +63,7 @@ function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
|
||||
const blocks = Array.isArray(newsletter.article_blocks)
|
||||
? newsletter.article_blocks.filter((b) => b.title)
|
||||
: [];
|
||||
const accent = newsletter.accent_color || "#3b82f6";
|
||||
const accent = newsletter.accent_color || "#E67E22";
|
||||
|
||||
const layoutLabel: Record<string, string> = {
|
||||
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="font-body text-[#888] text-xs leading-relaxed line-clamp-1">
|
||||
{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}
|
||||
</span>
|
||||
@@ -132,7 +132,7 @@ function NewsletterCard({ newsletter }: { newsletter: Newsletter }) {
|
||||
<span className="font-body text-[#555] text-xs">
|
||||
{blocks.length} {blocks.length === 1 ? "story" : "stories"}
|
||||
</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()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -197,9 +197,9 @@ function SignupCTA() {
|
||||
|
||||
{status === "success" ? (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<p className="font-heading text-[#e0e0e0] font-bold mb-1">You're subscribed!</p>
|
||||
@@ -228,7 +228,7 @@ function SignupCTA() {
|
||||
/>
|
||||
<span
|
||||
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 && (
|
||||
<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" />
|
||||
@@ -262,7 +262,7 @@ function SignupCTA() {
|
||||
<button
|
||||
type="submit"
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ function SignupCTA() {
|
||||
|
||||
<p className="font-body text-[#555] text-xs mt-3 text-center">
|
||||
No spam. Unsubscribe anytime.{" "}
|
||||
<Link href="/privacy" className="text-[#3b82f6] hover:underline">
|
||||
<Link href="/privacy" className="text-[#E67E22] hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
@@ -379,7 +379,7 @@ export default function NewsletterArchivePage() {
|
||||
</div>
|
||||
<h1 className="font-heading text-white text-3xl font-bold">Newsletter Archive</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,9 +419,9 @@ export default function NewsletterArchivePage() {
|
||||
<button
|
||||
key={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
|
||||
? "bg-[#3b82f6] border-[#3b82f6] text-white"
|
||||
? "bg-[#E67E22] border-[#E67E22] text-white"
|
||||
: "bg-transparent border-[#333] text-[#888] hover:border-[#555] hover:text-[#ccc]"
|
||||
}`}
|
||||
aria-pressed={selectedTopic === topic}>
|
||||
@@ -462,7 +462,7 @@ export default function NewsletterArchivePage() {
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
@@ -498,7 +498,7 @@ export default function NewsletterArchivePage() {
|
||||
<p className="font-body text-red-400 text-sm mb-3">{error}</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -521,7 +521,7 @@ export default function NewsletterArchivePage() {
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -555,9 +555,9 @@ export default function NewsletterArchivePage() {
|
||||
<li key={topic}>
|
||||
<button
|
||||
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
|
||||
? "text-[#3b82f6] bg-[#3b82f6]/10"
|
||||
? "text-[#E67E22] bg-[#E67E22]/10"
|
||||
: "text-[#888] hover:text-[#ccc] hover:bg-[#1a1a1a]"
|
||||
}`}>
|
||||
{topic}
|
||||
@@ -578,9 +578,9 @@ export default function NewsletterArchivePage() {
|
||||
<li key={year}>
|
||||
<button
|
||||
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)
|
||||
? "text-[#3b82f6] bg-[#3b82f6]/10"
|
||||
? "text-[#E67E22] bg-[#E67E22]/10"
|
||||
: "text-[#888] hover:text-[#ccc] hover:bg-[#1a1a1a]"
|
||||
}`}>
|
||||
{year}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "Newsletter — Broadcast Beat",
|
||||
description: "Broadcast Beat newsletter archive.",
|
||||
title: "Newsletter — AV Beat",
|
||||
description: "AV Beat newsletter archive.",
|
||||
alternates: { canonical: "/newsletter/archive" },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy — Broadcast Beat",
|
||||
description: "Broadcast Beat Privacy Policy — how we collect, use, and protect your personal information. GDPR and CCPA compliant.",
|
||||
title: "Privacy Policy — AV Beat",
|
||||
description: "AV Beat Privacy Policy — how we collect, use, and protect your personal information. GDPR and CCPA compliant.",
|
||||
alternates: {
|
||||
canonical: "/privacy",
|
||||
},
|
||||
@@ -26,7 +26,7 @@ export default function PrivacyPage() {
|
||||
|
||||
<section aria-labelledby="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>
|
||||
</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]">Unsubscribe:</strong> Opt out of marketing emails at any time via the unsubscribe link</li>
|
||||
</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 aria-labelledby="section-6">
|
||||
@@ -127,7 +127,7 @@ export default function PrivacyPage() {
|
||||
<address className="not-italic mt-3 space-y-1 text-[#999]">
|
||||
<p><strong className="text-[#ccc]">Relevant Media Properties, LLC</strong></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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function RegisterPage() {
|
||||
<div className="min-h-screen bg-[#111111]">
|
||||
<Header />
|
||||
<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>
|
||||
<Footer />
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ export default function RegisterPage() {
|
||||
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e]">
|
||||
<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]">
|
||||
<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 className="text-[#888]">Create Account</span>
|
||||
</nav>
|
||||
@@ -112,7 +112,7 @@ export default function RegisterPage() {
|
||||
Create Account
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function RegisterPage() {
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="Jane Smith"
|
||||
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>
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function RegisterPage() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
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>
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function RegisterPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 6 characters"
|
||||
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>
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function RegisterPage() {
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
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>
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<>
|
||||
<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 */}
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
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 {
|
||||
rules: [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 1800;
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://broadcastbeat.com";
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://avbeat.com";
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
@@ -41,7 +41,7 @@ export async function GET() {
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Broadcast Beat</title>
|
||||
<title>AV Beat</title>
|
||||
<link>${SITE_URL}</link>
|
||||
<atom:link href="${SITE_URL}/rss" rel="self" type="application/rss+xml" />
|
||||
<description>News & Intelligence for Broadcast, Post-Production & Streaming Technology</description>
|
||||
|
||||
@@ -11,9 +11,9 @@ import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Search — Broadcast Beat",
|
||||
title: "Search — AV Beat",
|
||||
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" },
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
||||
</div>
|
||||
<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>
|
||||
<p className="text-[#777] font-body text-sm mt-2">
|
||||
{query
|
||||
@@ -51,7 +51,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
{query && results.length === 0 && (
|
||||
<div className="py-16 text-center">
|
||||
<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 →
|
||||
</Link>
|
||||
</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">
|
||||
<AppImage
|
||||
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
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="160px"
|
||||
@@ -77,18 +77,18 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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"}
|
||||
</span>
|
||||
</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"}
|
||||
</h2>
|
||||
<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">
|
||||
<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-[#555] font-body text-xs">{article.readTime || ""}</span>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ async function fetchArticles(refs: ArticleRef[]): Promise<UnifiedArticle[]> {
|
||||
excerpt: r.excerpt || "",
|
||||
image: null,
|
||||
date: r.published_at || r.created_at,
|
||||
author: Array.isArray(r.persona) ? r.persona[0]?.name || "Broadcast Beat" : r.persona?.name || "Broadcast Beat",
|
||||
author: Array.isArray(r.persona) ? r.persona[0]?.name || "AV Beat" : r.persona?.name || "AV Beat",
|
||||
source_table: "ai_rewritten",
|
||||
});
|
||||
}
|
||||
@@ -101,7 +101,7 @@ async function fetchArticles(refs: ArticleRef[]): Promise<UnifiedArticle[]> {
|
||||
excerpt: r.excerpt || "",
|
||||
image: r.featured_image,
|
||||
date: r.wp_published_at,
|
||||
author: r.author_name || "Broadcast Beat",
|
||||
author: r.author_name || "AV Beat",
|
||||
source_table: "wp_imported",
|
||||
});
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export default async function EventCoveragePage({
|
||||
name: event.venue || event.city || event.country || "TBC",
|
||||
address: [event.venue, event.city, event.country].filter(Boolean).join(", "),
|
||||
},
|
||||
url: event.url || `https://broadcastbeat.com/show-coverage/${event.slug}`,
|
||||
url: event.url || `https://avbeat.com/show-coverage/${event.slug}`,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -170,7 +170,7 @@ export default async function EventCoveragePage({
|
||||
/>
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-8">
|
||||
<main>
|
||||
<Link href="/show-coverage" className="text-sm text-[var(--color-text-info,#60a5fa)] hover:underline">
|
||||
<Link href="/show-coverage" className="text-sm text-[var(--color-text-info,#E67E22)] hover:underline">
|
||||
← All events
|
||||
</Link>
|
||||
|
||||
@@ -182,7 +182,7 @@ export default async function EventCoveragePage({
|
||||
</span>
|
||||
)}
|
||||
{!isLive && !isPast && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-wider text-[var(--color-text-info,#E67E22)]">
|
||||
T-{daysUntil}d
|
||||
</span>
|
||||
)}
|
||||
@@ -203,7 +203,7 @@ export default async function EventCoveragePage({
|
||||
{[event.venue, event.city, event.country].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{event.url && (
|
||||
<div className="text-xs font-mono text-[var(--color-text-info,#60a5fa)] mt-2">
|
||||
<div className="text-xs font-mono text-[var(--color-text-info,#E67E22)] mt-2">
|
||||
<a href={event.url} target="_blank" rel="noopener noreferrer">
|
||||
{new URL(event.url).hostname.replace(/^www\./, "")} →
|
||||
</a>
|
||||
|
||||
@@ -123,7 +123,7 @@ function EventCard({ e }: { e: Row }) {
|
||||
<li>
|
||||
<Link
|
||||
href={`/show-coverage/${e.slug}`}
|
||||
className="block p-5 rounded border border-[#252525] hover:border-[var(--color-text-info,#60a5fa)] bg-[#0b0f17] transition-colors"
|
||||
className="block p-5 rounded border border-[#252525] hover:border-[var(--color-text-info,#E67E22)] bg-[#0b0f17] transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="font-serif text-xl font-semibold">{e.name}</h3>
|
||||
@@ -137,7 +137,7 @@ function EventCard({ e }: { e: Row }) {
|
||||
{e.url && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-[var(--color-text-info,#60a5fa)]">{new URL(e.url).hostname.replace(/^www\./, "")}</span>
|
||||
<span className="text-[var(--color-text-info,#E67E22)]">{new URL(e.url).hostname.replace(/^www\./, "")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -147,6 +147,6 @@ function EventCard({ e }: { e: Row }) {
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Industry Show Coverage — events tracked by Broadcast Beat",
|
||||
description: "All upcoming and past broadcast / production industry events tracked by Broadcast Beat, with linked coverage.",
|
||||
title: "Industry Show Coverage — events tracked by AV Beat",
|
||||
description: "All upcoming and past broadcast / production industry events tracked by AV Beat, with linked coverage.",
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MetadataRoute } from 'next';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
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
|
||||
// static + author + event entries we add below.
|
||||
|
||||
@@ -49,7 +49,7 @@ interface PageProps {
|
||||
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
|
||||
const sp = await searchParams;
|
||||
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.";
|
||||
return {
|
||||
title,
|
||||
@@ -87,7 +87,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
<span className="section-label">Technology</span>
|
||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
||||
{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
|
||||
</Link>
|
||||
)}
|
||||
@@ -101,7 +101,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
href="/technology"
|
||||
className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all ${
|
||||
!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]"
|
||||
}`}>
|
||||
All
|
||||
@@ -114,7 +114,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
href={`/technology?category=${slug}`}
|
||||
className={`px-3 py-1.5 rounded-sm font-body text-[11px] font-semibold uppercase tracking-wide transition-all ${
|
||||
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]"
|
||||
}`}>
|
||||
{def.title}
|
||||
@@ -133,7 +133,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
No articles found{filter ? ` for "${filter.title}"` : ""}.
|
||||
</p>
|
||||
{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
|
||||
</Link>
|
||||
)}
|
||||
@@ -145,7 +145,7 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
<div className="mb-10">
|
||||
<Link
|
||||
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">
|
||||
<AppImage
|
||||
src={articles[0].image}
|
||||
@@ -157,13 +157,13 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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-[#555] font-body text-[11px]">{articles[0].date}</span>
|
||||
<span className="text-[#444] text-[10px]">·</span>
|
||||
<span className="text-[#555] font-body text-[11px]">{articles[0].readTime}</span>
|
||||
</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}
|
||||
</h2>
|
||||
<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
|
||||
key={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">
|
||||
<AppImage
|
||||
src={article.image}
|
||||
@@ -197,11 +197,11 @@ export default async function TechnologyPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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-[#555] font-body text-[10px]">{article.readTime}</span>
|
||||
</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}
|
||||
</h2>
|
||||
{article.category !== "Featured" && (
|
||||
|
||||
@@ -4,8 +4,8 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service — Broadcast Beat",
|
||||
description: "Broadcast Beat Terms of Service — the rules governing use of our platform.",
|
||||
title: "Terms of Service — AV Beat",
|
||||
description: "AV Beat Terms of Service — the rules governing use of our platform.",
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
@@ -22,15 +22,15 @@ export default function TermsPage() {
|
||||
<div className="max-w-3xl space-y-8 font-body text-[#aaa] leading-relaxed">
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold mb-3">1. Acceptance of Terms</h2>
|
||||
<p>By accessing and using Broadcast Beat, you accept and agree to be bound by the terms and provisions of this agreement. If you do not agree to these terms, please do not use our service.</p>
|
||||
<p>By accessing and using AV Beat, you accept and agree to be bound by the terms and provisions of this agreement. If you do not agree to these terms, please do not use our service.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold mb-3">2. Use of Content</h2>
|
||||
<p>All content on Broadcast Beat is the property of Relevant Media Properties or its content suppliers and is protected by applicable intellectual property laws. You may not reproduce, distribute, or create derivative works from our content without express written permission.</p>
|
||||
<p>All content on AV Beat is the property of Relevant Media Properties or its content suppliers and is protected by applicable intellectual property laws. You may not reproduce, distribute, or create derivative works from our content without express written permission.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold mb-3">3. User Accounts</h2>
|
||||
<p>If you create an account on Broadcast Beat, you are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account. You agree to notify us immediately of any unauthorized use of your account.</p>
|
||||
<p>If you create an account on AV Beat, you are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account. You agree to notify us immediately of any unauthorized use of your account.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold mb-3">4. Community Standards</h2>
|
||||
@@ -38,7 +38,7 @@ export default function TermsPage() {
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold mb-3">5. Disclaimer of Warranties</h2>
|
||||
<p>Broadcast Beat is provided "as is" without warranty of any kind. We do not warrant that the service will be uninterrupted or error-free. We make no warranties regarding the accuracy or completeness of any content on the site.</p>
|
||||
<p>AV Beat is provided "as is" without warranty of any kind. We do not warrant that the service will be uninterrupted or error-free. We make no warranties regarding the accuracy or completeness of any content on the site.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0e0] text-xl font-bold mb-3">6. Limitation of Liability</h2>
|
||||
@@ -46,7 +46,7 @@ export default function TermsPage() {
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="font-heading text-[#e0e0f0] text-xl font-bold mb-3">7. Contact</h2>
|
||||
<p>Questions about these Terms? Contact us at <a href="mailto:legal@broadcastbeat.com" className="text-[#3b82f6] hover:underline">legal@broadcastbeat.com</a>.</p>
|
||||
<p>Questions about these Terms? Contact us at <a href="mailto:legal@avbeat.com" className="text-[#E67E22] hover:underline">legal@avbeat.com</a>.</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -103,16 +103,16 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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" />
|
||||
</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
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
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">
|
||||
<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)}
|
||||
className={`px-2 py-0.5 text-[10px] font-body rounded-sm border transition-colors ${
|
||||
interests.includes(topic)
|
||||
? 'bg-[#3b82f6] border-[#3b82f6] text-white'
|
||||
: 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]'
|
||||
? 'bg-[#E67E22] border-[#E67E22] text-white'
|
||||
: 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]'
|
||||
}`}
|
||||
>
|
||||
{topic}
|
||||
@@ -142,7 +142,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
|
||||
</div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -180,10 +180,10 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
</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}
|
||||
</p>
|
||||
</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 gap-3">
|
||||
<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" />
|
||||
</svg>
|
||||
<span className="section-label">AI Picks For You</span>
|
||||
@@ -212,7 +212,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
|
||||
</div>
|
||||
<button
|
||||
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">
|
||||
<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)}
|
||||
className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${
|
||||
interests.includes(topic)
|
||||
? 'bg-[#3b82f6] border-[#3b82f6] text-white'
|
||||
: 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]'
|
||||
? 'bg-[#E67E22] border-[#E67E22] text-white'
|
||||
: 'bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#E67E22] hover:text-[#E67E22]'
|
||||
}`}
|
||||
>
|
||||
{topic}
|
||||
@@ -242,7 +242,7 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
|
||||
</div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -280,11 +280,11 @@ export default function AISuggestedArticles({ variant = 'compact' }: Props) {
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
/>
|
||||
<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}
|
||||
</span>
|
||||
</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}
|
||||
</h3>
|
||||
<p className="text-[#555] font-body text-[11px] mt-1">{article.readTime}</p>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function AboutDropdown() {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="true"
|
||||
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 aria-hidden="true" className="text-[10px]">▾</span>
|
||||
@@ -54,7 +54,7 @@ export default function AboutDropdown() {
|
||||
href={it.href}
|
||||
role="menuitem"
|
||||
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 aria-hidden="true" className="text-[10px] text-[#6b7280]">›</span>
|
||||
|
||||
315
src/components/ArticleComments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function AskBBAI() {
|
||||
{
|
||||
role: "assistant",
|
||||
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);
|
||||
@@ -71,7 +71,7 @@ export default function AskBBAI() {
|
||||
aria-label="Ask BB AI"
|
||||
>
|
||||
<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>
|
||||
<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]">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<h2 className="font-serif text-lg font-semibold text-white">BB AI</h2>
|
||||
@@ -109,7 +109,7 @@ export default function AskBBAI() {
|
||||
className={
|
||||
"inline-block max-w-[85%] rounded px-3 py-2 text-sm whitespace-pre-wrap " +
|
||||
(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]")
|
||||
}
|
||||
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()}
|
||||
placeholder="Ask about an event, a vendor, a workflow…"
|
||||
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
|
||||
onClick={send}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@ function linkifyMarkdown(s: string): string {
|
||||
let out = escapeHtml(s);
|
||||
out = out.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/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/>");
|
||||
return out;
|
||||
|
||||
@@ -57,7 +57,7 @@ export default async function CompaniesInThisStory({ slugs }: { slugs: string[]
|
||||
|
||||
return (
|
||||
<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
|
||||
</h3>
|
||||
<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="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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -112,12 +112,12 @@ export default function CompanyMentionsHover() {
|
||||
/* 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" />
|
||||
) : (
|
||||
<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()}
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
</Link>
|
||||
<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">
|
||||
{preview.stories.slice(0, 3).map((s) => (
|
||||
<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}
|
||||
</Link>
|
||||
{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">
|
||||
<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 →
|
||||
</Link>
|
||||
{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(/\/$/, "")} ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -72,11 +72,11 @@ export default function CookieConsent() {
|
||||
id="cookie-consent-desc"
|
||||
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.{" "}
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
@@ -86,7 +86,7 @@ export default function CookieConsent() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Decline
|
||||
@@ -95,7 +95,7 @@ export default function CookieConsent() {
|
||||
ref={acceptRef}
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Accept All
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function EventsDropdown() {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="true"
|
||||
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 aria-hidden="true" className="text-[10px]">▾</span>
|
||||
@@ -113,12 +113,12 @@ export default function EventsDropdown() {
|
||||
style={{ borderRadius: "var(--border-radius-md,6px)" }}
|
||||
>
|
||||
<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 bottom-2 right-2 w-3 h-3 border-b border-r 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,#E67E22)]"></span>
|
||||
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<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" />
|
||||
BB AI
|
||||
</span>
|
||||
@@ -158,7 +158,7 @@ export default function EventsDropdown() {
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</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>
|
||||
@@ -168,7 +168,7 @@ export default function EventsDropdown() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-[#6b7280] mt-1">
|
||||
<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>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -177,7 +177,7 @@ export default function EventsDropdown() {
|
||||
</ul>
|
||||
|
||||
<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 →
|
||||
</Link>
|
||||
{loadedAt && <span>auto-updated · {ago(loadedAt)}</span>}
|
||||
|
||||
@@ -73,7 +73,7 @@ export default async function FeaturedBento() {
|
||||
<div className="flex items-center gap-3 mb-4 md:mb-5 flex-wrap">
|
||||
<span className="section-label">Staff Editorial</span>
|
||||
<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 →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ export default async function FeaturedBento() {
|
||||
{/* Cinematic 21:9 hero — fills left column, aspect ratio preserved */}
|
||||
<Link
|
||||
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]">
|
||||
<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" />
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -108,11 +108,11 @@ export default async function FeaturedBento() {
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 md:p-10">
|
||||
{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}
|
||||
</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}
|
||||
</h3>
|
||||
{hero.excerpt && (
|
||||
@@ -121,9 +121,9 @@ export default async function FeaturedBento() {
|
||||
</p>
|
||||
)}
|
||||
<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" />
|
||||
<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>
|
||||
@@ -140,7 +140,7 @@ export default async function FeaturedBento() {
|
||||
<Link
|
||||
key={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">
|
||||
<img
|
||||
@@ -150,10 +150,10 @@ export default async function FeaturedBento() {
|
||||
/>
|
||||
</div>
|
||||
<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"}
|
||||
</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}
|
||||
</h4>
|
||||
<div className="mt-auto">
|
||||
|
||||
@@ -54,7 +54,7 @@ export default async function FeaturedCarouselServer() {
|
||||
|
||||
for (const r of (ai.data || []) as any[]) {
|
||||
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({
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
@@ -74,7 +74,7 @@ export default async function FeaturedCarouselServer() {
|
||||
|
||||
for (const r of (wp.data || []) as any[]) {
|
||||
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({
|
||||
id: String(r.wp_id),
|
||||
slug: r.wp_slug,
|
||||
|
||||
@@ -15,7 +15,7 @@ const footerColumns = [
|
||||
{
|
||||
heading: "About",
|
||||
links: [
|
||||
{ label: "About Broadcast Beat", href: "/about" },
|
||||
{ label: "About AV Beat", href: "/about" },
|
||||
{ label: "Our Mission", href: "/about#mission" },
|
||||
{ label: "Editorial Team", href: "/about#team" },
|
||||
{ label: "Advertise With Us", href: "/advertise" },
|
||||
@@ -50,11 +50,11 @@ const footerColumns = [
|
||||
{
|
||||
heading: "Follow Us",
|
||||
links: [
|
||||
{ label: "LinkedIn", href: "https://linkedin.com/company/broadcastbeat" },
|
||||
{ label: "Twitter / X", href: "https://twitter.com/broadcastbeat" },
|
||||
{ label: "Facebook", href: "https://facebook.com/broadcastbeat" },
|
||||
{ label: "Instagram", href: "https://instagram.com/broadcastbeat" },
|
||||
{ label: "YouTube", href: "https://youtube.com/@broadcastbeat" },
|
||||
{ label: "LinkedIn", href: "https://linkedin.com/company/avbeat" },
|
||||
{ label: "Twitter / X", href: "https://twitter.com/avbeat" },
|
||||
{ label: "Facebook", href: "https://facebook.com/avbeat" },
|
||||
{ label: "Instagram", href: "https://instagram.com/avbeat" },
|
||||
{ label: "YouTube", href: "https://youtube.com/@avbeat" },
|
||||
{ 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">
|
||||
{footerColumns?.map((col) =>
|
||||
<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}
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
@@ -86,13 +86,13 @@ export default function Footer() {
|
||||
href={link?.href}
|
||||
target="_blank"
|
||||
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}
|
||||
</a> :
|
||||
|
||||
<Link
|
||||
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>
|
||||
}
|
||||
@@ -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">
|
||||
<Link
|
||||
href="/home-page"
|
||||
aria-label="Broadcast Beat — home"
|
||||
aria-label="AV Beat — home"
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<AppImage
|
||||
src="/assets/images/logo.png"
|
||||
alt="Broadcast Beat"
|
||||
alt="AV Beat"
|
||||
width={180}
|
||||
height={24}
|
||||
className="h-6 w-auto object-contain" />
|
||||
height={42}
|
||||
className="h-9 w-auto object-contain" />
|
||||
</Link>
|
||||
|
||||
<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"
|
||||
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon">
|
||||
<a href="https://linkedin.com/company/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on LinkedIn"
|
||||
className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
|
||||
<LinkedInIcon size={17} />
|
||||
</a>
|
||||
<a href="https://instagram.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Instagram"
|
||||
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon">
|
||||
<a href="https://instagram.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Instagram"
|
||||
className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
|
||||
<InstagramIcon size={17} />
|
||||
</a>
|
||||
<a href="https://twitter.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Twitter/X"
|
||||
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon">
|
||||
<a href="https://twitter.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Twitter/X"
|
||||
className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
|
||||
<TwitterXIcon size={17} />
|
||||
</a>
|
||||
<a href="https://facebook.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast Beat on Facebook"
|
||||
className="text-[#555] hover:text-[#3b82f6] transition-colors social-icon">
|
||||
<a href="https://facebook.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Facebook"
|
||||
className="text-[#555] hover:text-[#E67E22] transition-colors social-icon">
|
||||
<FacebookIcon size={17} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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="/terms" className="hover:text-[#3b82f6] transition-colors">Terms of Service</Link>
|
||||
<Link href="/privacy" className="hover:text-[#E67E22] transition-colors">Privacy Policy</Link>
|
||||
<Link href="/terms" className="hover:text-[#E67E22] transition-colors">Terms of Service</Link>
|
||||
<span>
|
||||
© 2026 <a
|
||||
href="https://www.relevantmediaproperties.com/"
|
||||
target="_blank"
|
||||
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
|
||||
</a>. All rights reserved.
|
||||
</span>
|
||||
|
||||
@@ -261,14 +261,14 @@ export default function Header() {
|
||||
<div className="max-w-container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-[96px] gap-4">
|
||||
{/* 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
|
||||
src="/assets/images/logo.png"
|
||||
alt="Broadcast Beat logo"
|
||||
alt="AV Beat logo"
|
||||
width={200}
|
||||
height={26}
|
||||
height={47}
|
||||
priority={true}
|
||||
className="h-7 w-auto object-contain"
|
||||
className="h-10 w-auto object-contain"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -283,7 +283,7 @@ export default function Header() {
|
||||
<Link
|
||||
href="/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">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
@@ -296,7 +296,7 @@ export default function Header() {
|
||||
{/* Mobile Hamburger */}
|
||||
<button
|
||||
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)}
|
||||
aria-label={mobileOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={mobileOpen}
|
||||
@@ -319,7 +319,7 @@ export default function Header() {
|
||||
key={link?.label}
|
||||
href={link?.href}
|
||||
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)}>
|
||||
{link?.label}
|
||||
</Link>
|
||||
@@ -327,7 +327,7 @@ export default function Header() {
|
||||
{/* Reading List mobile link */}
|
||||
<Link
|
||||
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)}>
|
||||
<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" />
|
||||
@@ -338,7 +338,7 @@ export default function Header() {
|
||||
{currentUserId && (
|
||||
<Link
|
||||
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)}>
|
||||
<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" />
|
||||
@@ -350,7 +350,7 @@ export default function Header() {
|
||||
{isAdmin && (
|
||||
<Link
|
||||
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)}>
|
||||
<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" />
|
||||
@@ -364,14 +364,14 @@ export default function Header() {
|
||||
.filter((n) => n.dropdown && n.dropdown.length > 0)
|
||||
.map((section) => (
|
||||
<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}
|
||||
</div>
|
||||
{section.dropdown!.map((link) => (
|
||||
<Link
|
||||
key={link.href + link.label}
|
||||
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)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
@@ -399,7 +399,7 @@ export default function Header() {
|
||||
type="button"
|
||||
onClick={submitSearch}
|
||||
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} />
|
||||
</button>
|
||||
@@ -418,22 +418,22 @@ export default function Header() {
|
||||
<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">
|
||||
<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>
|
||||
<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"
|
||||
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="https://linkedin.com/company/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on LinkedIn"
|
||||
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} />
|
||||
</a>
|
||||
<a href="https://instagram.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast 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">
|
||||
<a href="https://instagram.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Instagram"
|
||||
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} />
|
||||
</a>
|
||||
<a href="https://facebook.com/broadcastbeat" target="_blank" rel="noopener noreferrer" aria-label="Broadcast 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">
|
||||
<a href="https://facebook.com/avbeat" target="_blank" rel="noopener noreferrer" aria-label="AV Beat on Facebook"
|
||||
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} />
|
||||
</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} />
|
||||
</a>
|
||||
<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" />
|
||||
{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
|
||||
</Link>
|
||||
<button
|
||||
@@ -452,33 +452,33 @@ export default function Header() {
|
||||
await supabase.auth.signOut();
|
||||
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
|
||||
</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
|
||||
</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
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</Link>
|
||||
|
||||
@@ -487,7 +487,7 @@ export default function Header() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
<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" />
|
||||
@@ -497,25 +497,25 @@ export default function Header() {
|
||||
role="menu"
|
||||
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">
|
||||
<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/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/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/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/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/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="/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/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="/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/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-[#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-[#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-[#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-[#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-[#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-[#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-[#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-[#E67E22] focus:outline-none focus-visible:bg-[#1a1a1a] focus-visible:text-[#E67E22] uppercase tracking-wider transition-colors">Banned Terms</Link>
|
||||
</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
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{/* Inline Email Subscription Form */}
|
||||
{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">
|
||||
<input
|
||||
@@ -525,12 +525,12 @@ export default function Header() {
|
||||
placeholder="your@email.com"
|
||||
aria-label="Email address for newsletter"
|
||||
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
|
||||
type="submit"
|
||||
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"}
|
||||
</button>
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function LanguageSwitcher({ currentLang, onLanguageChange, compac
|
||||
<div ref={dropdownRef} className="relative z-[100]">
|
||||
<button
|
||||
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-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
@@ -184,14 +184,14 @@ export default function LanguageSwitcher({ currentLang, onLanguageChange, compac
|
||||
onClick={() => handleSelect(lang.code)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs transition-colors text-left ${
|
||||
activeLang === lang.code
|
||||
? 'bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||
? 'bg-[#E67E22]/10 text-[#E67E22]'
|
||||
: 'text-[#888] hover:text-white hover:bg-[#1a1a1a]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{lang.flag}</span>
|
||||
<span>{lang.nativeName}</span>
|
||||
{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" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function NotificationCenter() {
|
||||
aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ""}`}
|
||||
aria-expanded={open}
|
||||
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">
|
||||
<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 && (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -225,7 +225,7 @@ export default function NotificationCenter() {
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -233,7 +233,7 @@ export default function NotificationCenter() {
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
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">
|
||||
<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
|
||||
key={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
|
||||
? "text-[#3b82f6] border-b-2 border-[#3b82f6]"
|
||||
? "text-[#E67E22] border-b-2 border-[#E67E22]"
|
||||
: "text-[#666] hover:text-[#aaa]"
|
||||
}`}
|
||||
>
|
||||
@@ -359,7 +359,7 @@ function NotificationItem({ notification: n }: { notification: Notification }) {
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
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">
|
||||
<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] text-[#444]">{timeAgo(n.created_at)}</span>
|
||||
{!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>
|
||||
@@ -379,7 +379,7 @@ function NotificationItem({ notification: n }: { notification: Notification }) {
|
||||
|
||||
if (n.link) {
|
||||
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}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@ import { useEffect, useState } from "react";
|
||||
* On mount we assemble the address client-side and wrap in a real mailto.
|
||||
*
|
||||
* 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:
|
||||
* <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
|
||||
* pre-JS DOM won't get a clean string.
|
||||
|
||||
@@ -34,7 +34,7 @@ const ToolbarButton = ({
|
||||
title={title}
|
||||
className={`p-1.5 rounded transition-colors text-sm ${
|
||||
active
|
||||
? 'bg-[#3b82f6] text-white'
|
||||
? 'bg-[#E67E22] text-white'
|
||||
: 'text-[#888] hover:text-[#cccccc] hover:bg-[#252525]'
|
||||
}`}
|
||||
>
|
||||
@@ -53,7 +53,7 @@ export default function RichTextEditor({ value, onChange, placeholder = 'Start w
|
||||
Color,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
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 }),
|
||||
],
|
||||
content: value,
|
||||
|
||||
@@ -70,11 +70,11 @@ export default async function SponsorLogoStrip() {
|
||||
return (
|
||||
<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">
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-bold">
|
||||
<span className="text-[10px] uppercase tracking-widest text-[#E67E22] font-bold">
|
||||
Content Partners
|
||||
</span>
|
||||
<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 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
116
src/components/auth/SocialAuthButtons.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
91
src/components/forum/ImageAttachButton.tsx
Normal 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 `` 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\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>
|
||||
);
|
||||
}
|
||||
83
src/components/forum/RichBody.tsx
Normal 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 `` 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>
|
||||
);
|
||||
}
|
||||
@@ -408,6 +408,99 @@ export async function searchLegacyArticles(
|
||||
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[]> {
|
||||
let legacy: string[] = [];
|
||||
let rewrites: string[] = [];
|
||||
|
||||
139
src/lib/images/optimize.ts
Normal 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 };
|
||||
}
|
||||
@@ -18,6 +18,6 @@ export function createAdminClient(schema?: string) {
|
||||
}
|
||||
return createClient(url, key, {
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export function createClient() {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
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: {
|
||||
getAll() {
|
||||
if (typeof document === 'undefined') return [];
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function createClient() {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
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: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
|
||||
@@ -3,21 +3,22 @@
|
||||
homepage components. Existing tokens kept intact (forward-only). */
|
||||
|
||||
:root {
|
||||
/* AI accent (blue) — same hue as --color-accent but exposed via the
|
||||
spec name --color-text-info so the redesign components can reference
|
||||
it without coupling to the existing --color-accent. */
|
||||
--color-text-info: #60a5fa;
|
||||
--color-text-info-strong: #3b82f6;
|
||||
/* AV Beat palette — orange-led, near-black canvas. The legacy
|
||||
`--color-text-info` and `*-strong` names retain their semantic
|
||||
meaning (informational accent) but now carry the AV orange so
|
||||
ticker dots, AI rings and inline accents read as AV Beat. */
|
||||
--color-text-info: #E67E22;
|
||||
--color-text-info-strong: #E67E22;
|
||||
|
||||
/* Backgrounds */
|
||||
--color-background-primary: #0a0a0a;
|
||||
--color-background-secondary: #111111;
|
||||
--color-background-tertiary: #1a1f2e;
|
||||
--color-background-primary: #0F0E0E;
|
||||
--color-background-secondary: #1D1A1A;
|
||||
--color-background-tertiary: #221c18;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #e8e8e8;
|
||||
--color-text-secondary: #b0b0b0;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-text-primary: #F0EBE6;
|
||||
--color-text-secondary: #b8b1aa;
|
||||
--color-text-tertiary: #6b6560;
|
||||
|
||||
/* Brand surfaces */
|
||||
--color-text-success: #10b981;
|
||||
|
||||
@@ -3,20 +3,21 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #0d0d0d;
|
||||
--color-secondary: #161616;
|
||||
--color-accent: #3b82f6;
|
||||
--color-accent-dark: #2563eb;
|
||||
--color-accent-muted: #1e3a5f;
|
||||
--color-accent-red: #cc0000;
|
||||
--color-accent-red-dark: #aa0000;
|
||||
--color-bg: #111111;
|
||||
--color-card: #1a1a1a;
|
||||
--color-foreground: #e8e8e8;
|
||||
--color-muted: #888888;
|
||||
--color-border: #2a2a2a;
|
||||
--color-top-bar: #1a2535;
|
||||
--color-sub-nav: #161c28;
|
||||
/* AV Beat palette — orange/near-black, "Where AV Meets IT" */
|
||||
--color-primary: #0F0E0E;
|
||||
--color-secondary: #1D1A1A;
|
||||
--color-accent: #E67E22;
|
||||
--color-accent-dark: #C8651B;
|
||||
--color-accent-muted: #5a3413;
|
||||
--color-accent-red: #E67E22;
|
||||
--color-accent-red-dark: #C8651B;
|
||||
--color-bg: #0F0E0E;
|
||||
--color-card: #1D1A1A;
|
||||
--color-foreground: #F0EBE6;
|
||||
--color-muted: #8a847e;
|
||||
--color-border: #2a2624;
|
||||
--color-top-bar: #1D1A1A;
|
||||
--color-sub-nav: #161313;
|
||||
/* --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
|
||||
fallbacks for the brief window before the self-hosted face is ready. */
|
||||
|
||||
@@ -8,23 +8,24 @@ module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0d0d0d',
|
||||
secondary: '#161616',
|
||||
accent: '#3b82f6',
|
||||
'accent-dark': '#2563eb',
|
||||
'accent-muted': '#1e3a5f',
|
||||
'accent-red': '#cc0000',
|
||||
'accent-red-dark': '#aa0000',
|
||||
background: '#111111',
|
||||
card: '#1a1a1a',
|
||||
foreground: '#e8e8e8',
|
||||
muted: '#888888',
|
||||
border: '#2a2a2a',
|
||||
'navy-mid': '#1e1e1e',
|
||||
'navy-light': '#222222',
|
||||
'ice-blue': '#1a2535',
|
||||
'top-bar': '#1a2535',
|
||||
'sub-nav': '#161c28',
|
||||
// AV Beat palette — orange/near-black, "Where AV Meets IT"
|
||||
primary: '#0F0E0E',
|
||||
secondary: '#1D1A1A',
|
||||
accent: '#E67E22',
|
||||
'accent-dark': '#C8651B',
|
||||
'accent-muted': '#5a3413',
|
||||
'accent-red': '#E67E22',
|
||||
'accent-red-dark': '#C8651B',
|
||||
background: '#0F0E0E',
|
||||
card: '#1D1A1A',
|
||||
foreground: '#F0EBE6',
|
||||
muted: '#8a847e',
|
||||
border: '#2a2624',
|
||||
'navy-mid': '#1e1a18',
|
||||
'navy-light': '#221c18',
|
||||
'ice-blue': '#1D1A1A',
|
||||
'top-bar': '#1D1A1A',
|
||||
'sub-nav': '#161313',
|
||||
},
|
||||
fontFamily: {
|
||||
heading: ['Georgia', 'serif'],
|
||||
|
||||