- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch) - Domain: broadcastbeat.com -> avbeat.com - Brand: Broadcast Beat -> AV Beat - Slug: broadcastbeat -> avbeat (package, identifiers) - Schema fallback: bb -> av (env var name unchanged) - Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg - All BB/RMP logo references repointed Outstanding before public DNS swap: - Supabase av schema bootstrap (mirror of bb tables) - WordPress archive import (avbeat-com -> av.articles) - Coolify env vars - dev-avbeat.onsethost.com staging deploy + visual review
902 lines
41 KiB
TypeScript
902 lines
41 KiB
TypeScript
"use client";
|
||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||
import Link from "next/link";
|
||
import AppImage from "@/components/ui/AppImage";
|
||
import Header from "@/components/Header";
|
||
import Footer from "@/components/Footer";
|
||
import AdSlot from "@/components/AdSlot";
|
||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||
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;
|
||
}
|
||
|
||
function getSessionId(): string {
|
||
if (typeof window === "undefined") return "";
|
||
let sid = sessionStorage.getItem("bb_session_id");
|
||
if (!sid) {
|
||
sid = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||
sessionStorage.setItem("bb_session_id", sid);
|
||
}
|
||
return sid;
|
||
}
|
||
|
||
export default function NewsArticleDetailClient({
|
||
article,
|
||
relatedArticles,
|
||
relatedForumThreads = [],
|
||
companiesInStory,
|
||
}: NewsArticleDetailClientProps) {
|
||
const [isSaved, setIsSaved] = useState(false);
|
||
const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle");
|
||
const [saveToast, setSaveToast] = useState<string | null>(null);
|
||
const [currentUser, setCurrentUser] = useState<{ id: string } | null>(null);
|
||
const [emailShareOpen, setEmailShareOpen] = useState(false);
|
||
const [emailTo, setEmailTo] = useState("");
|
||
const [emailNote, setEmailNote] = useState("");
|
||
const [emailSending, setEmailSending] = useState(false);
|
||
const [emailStatus, setEmailStatus] = useState<"idle" | "success" | "error">("idle");
|
||
const [carouselIndex, setCarouselIndex] = useState(0);
|
||
const articleStartTime = useRef<number>(Date.now());
|
||
const hasTrackedView = useRef(false);
|
||
const supabase = createClient();
|
||
|
||
const CARDS_VISIBLE = 3;
|
||
const maxIndex = Math.max(0, relatedArticles.length - CARDS_VISIBLE);
|
||
|
||
// Get current user
|
||
useEffect(() => {
|
||
supabase?.auth?.getUser().then(({ data }) => {
|
||
setCurrentUser(data?.user ?? null);
|
||
});
|
||
}, []);
|
||
|
||
// Track article view
|
||
useEffect(() => {
|
||
if (hasTrackedView.current || !article?.slug) return;
|
||
hasTrackedView.current = true;
|
||
articleStartTime.current = Date.now();
|
||
const trackView = async () => {
|
||
try {
|
||
const { data: userData } = await supabase?.auth?.getUser();
|
||
await supabase?.from("article_analytics")?.insert({
|
||
article_slug: article.slug,
|
||
article_title: article.title,
|
||
user_id: userData?.user?.id ?? null,
|
||
session_id: getSessionId(),
|
||
event_type: "view",
|
||
read_time_seconds: 0,
|
||
scroll_depth: 0,
|
||
referrer: typeof document !== "undefined" ? document.referrer : "",
|
||
});
|
||
|
||
// Track reading history and topic preferences for authenticated users
|
||
if (userData?.user?.id) {
|
||
// Upsert reading history
|
||
await supabase?.from("reading_history")?.upsert(
|
||
{
|
||
user_id: userData.user.id,
|
||
article_slug: article.slug,
|
||
article_title: article.title,
|
||
article_excerpt: article.excerpt ?? null,
|
||
article_image: article.image ?? null,
|
||
article_image_alt: article.alt ?? null,
|
||
article_category: article.category ?? null,
|
||
article_author: article.author ?? null,
|
||
article_read_time: article.readTime ?? null,
|
||
article_date: article.date ?? null,
|
||
viewed_at: new Date().toISOString(),
|
||
},
|
||
{ onConflict: "user_id,article_slug" }
|
||
);
|
||
|
||
// Upsert topic preference
|
||
if (article.category) {
|
||
const topic = article.category.toLowerCase().trim();
|
||
const { data: existing } = await supabase
|
||
?.from("user_topic_preferences")
|
||
?.select("id, view_count")
|
||
?.eq("user_id", userData.user.id)
|
||
?.eq("topic", topic)
|
||
?.maybeSingle();
|
||
|
||
if (existing) {
|
||
await supabase
|
||
?.from("user_topic_preferences")
|
||
?.update({
|
||
view_count: (existing.view_count || 0) + 1,
|
||
last_viewed_at: new Date().toISOString(),
|
||
})
|
||
?.eq("id", existing.id);
|
||
} else {
|
||
await supabase?.from("user_topic_preferences")?.insert({
|
||
user_id: userData.user.id,
|
||
topic,
|
||
view_count: 1,
|
||
last_viewed_at: new Date().toISOString(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Silent fail
|
||
}
|
||
};
|
||
trackView();
|
||
}, [article?.slug]);
|
||
|
||
// Track read completion on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
if (!article?.slug) return;
|
||
const readSeconds = Math.round((Date.now() - articleStartTime.current) / 1000);
|
||
if (readSeconds < 5) return;
|
||
const trackRead = async () => {
|
||
try {
|
||
const { data: userData } = await supabase?.auth?.getUser();
|
||
await supabase?.from("article_analytics")?.insert({
|
||
article_slug: article.slug,
|
||
article_title: article.title,
|
||
user_id: userData?.user?.id ?? null,
|
||
session_id: getSessionId(),
|
||
event_type: "read",
|
||
read_time_seconds: readSeconds,
|
||
scroll_depth: 0,
|
||
referrer: typeof document !== "undefined" ? document.referrer : "",
|
||
});
|
||
} catch {
|
||
// Silent fail
|
||
}
|
||
};
|
||
trackRead();
|
||
};
|
||
}, [article?.slug]);
|
||
|
||
// Check if article is saved
|
||
useEffect(() => {
|
||
if (!currentUser || !article?.slug) return;
|
||
const checkSaved = async () => {
|
||
try {
|
||
const { data } = await supabase
|
||
?.from("reading_list")
|
||
?.select("id")
|
||
?.eq("user_id", currentUser.id)
|
||
?.eq("article_slug", article.slug)
|
||
?.maybeSingle();
|
||
setIsSaved(!!data);
|
||
} catch {
|
||
// Silent fail
|
||
}
|
||
};
|
||
checkSaved();
|
||
}, [currentUser, article?.slug]);
|
||
|
||
const handleSaveToReadingList = useCallback(async () => {
|
||
if (!currentUser) {
|
||
setSaveToast("Sign in to save articles to your reading list");
|
||
setTimeout(() => setSaveToast(null), 3000);
|
||
return;
|
||
}
|
||
if (isSaved) {
|
||
setSavingState("removing");
|
||
try {
|
||
await supabase
|
||
?.from("reading_list")
|
||
?.delete()
|
||
?.eq("user_id", currentUser.id)
|
||
?.eq("article_slug", article.slug);
|
||
setIsSaved(false);
|
||
setSaveToast("Removed from reading list");
|
||
} catch {
|
||
setSaveToast("Failed to remove article");
|
||
} finally {
|
||
setSavingState("idle");
|
||
setTimeout(() => setSaveToast(null), 2500);
|
||
}
|
||
} else {
|
||
setSavingState("saving");
|
||
try {
|
||
await supabase?.from("reading_list")?.insert({
|
||
user_id: currentUser.id,
|
||
article_slug: article.slug,
|
||
article_title: article.title,
|
||
article_excerpt: article.excerpt,
|
||
article_image: article.image,
|
||
article_image_alt: article.alt,
|
||
article_category: article.category,
|
||
article_author: article.author,
|
||
article_read_time: article.readTime,
|
||
article_date: article.date,
|
||
});
|
||
setIsSaved(true);
|
||
setSaveToast("Saved to reading list");
|
||
} catch {
|
||
setSaveToast("Failed to save article");
|
||
} finally {
|
||
setSavingState("idle");
|
||
setTimeout(() => setSaveToast(null), 2500);
|
||
}
|
||
}
|
||
}, [currentUser, isSaved, article]);
|
||
|
||
const handleEmailShare = useCallback(async () => {
|
||
if (!emailTo.trim()) return;
|
||
setEmailSending(true);
|
||
setEmailStatus("idle");
|
||
try {
|
||
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" },
|
||
body: JSON.stringify({
|
||
to: emailTo.trim(),
|
||
articleTitle: article.title,
|
||
articleUrl,
|
||
articleExcerpt: article.excerpt,
|
||
note: emailNote.trim(),
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
setEmailStatus("success");
|
||
setEmailTo("");
|
||
setEmailNote("");
|
||
setTimeout(() => {
|
||
setEmailShareOpen(false);
|
||
setEmailStatus("idle");
|
||
}, 2500);
|
||
} else {
|
||
setEmailStatus("error");
|
||
}
|
||
} catch {
|
||
setEmailStatus("error");
|
||
} finally {
|
||
setEmailSending(false);
|
||
}
|
||
}, [emailTo, emailNote, article]);
|
||
|
||
const prevCarousel = () => setCarouselIndex((i) => Math.max(0, i - 1));
|
||
const nextCarousel = () => setCarouselIndex((i) => Math.min(maxIndex, i + 1));
|
||
|
||
const articleUrl =
|
||
typeof window !== "undefined"
|
||
? window.location.href
|
||
: `https://avbeat.com/news/${article.slug}`;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<Header />
|
||
|
||
{/* 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">
|
||
Home
|
||
</Link>
|
||
<span>/</span>
|
||
<Link href="/news" className="hover:text-[#3b82f6] transition-colors">
|
||
News
|
||
</Link>
|
||
<span>/</span>
|
||
<span className="text-[#888] truncate max-w-[300px]">{article.title}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Article */}
|
||
<article className="max-w-container mx-auto px-4 py-8 md:py-12">
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Main Content */}
|
||
<div className="lg:col-span-8">
|
||
{/* Article Header */}
|
||
<div className="mb-8">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<Link
|
||
href="/news"
|
||
className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:underline">
|
||
{article.category}
|
||
</Link>
|
||
<span className="text-xs text-[#777]">{article.readTime}</span>
|
||
</div>
|
||
|
||
<h1 className="font-heading text-3xl md:text-4xl font-bold mb-4 leading-tight text-[#f0f0f0]">
|
||
{article.title}
|
||
</h1>
|
||
<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">
|
||
<AppImage
|
||
src={article.authorAvatar}
|
||
alt={`${article.author} — ${article.authorTitle}`}
|
||
width={40}
|
||
height={40}
|
||
className="w-10 h-10 rounded-full object-cover"
|
||
/>
|
||
<div>
|
||
<Link
|
||
href={`/authors/${article.authorSlug}`}
|
||
className="font-heading font-bold text-sm hover:text-[#3b82f6] transition-colors text-[#e0e0e0]">
|
||
{article.author}
|
||
</Link>
|
||
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Action buttons */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* Email Share Button */}
|
||
<button
|
||
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]">
|
||
<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>
|
||
</button>
|
||
|
||
{/* LinkedIn */}
|
||
<a
|
||
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(articleUrl)}`}
|
||
target="_blank" rel="noopener noreferrer"
|
||
aria-label="Share on LinkedIn"
|
||
title="LinkedIn"
|
||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#0a66c2] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#0a66c2]">
|
||
<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>
|
||
|
||
{/* X / Twitter */}
|
||
<a
|
||
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=BroadcastBeat`}
|
||
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 focus:outline-none focus-visible:ring-1 focus-visible:ring-white">
|
||
<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>
|
||
</a>
|
||
|
||
{/* Facebook */}
|
||
<a
|
||
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(articleUrl)}`}
|
||
target="_blank" rel="noopener noreferrer"
|
||
aria-label="Share on Facebook"
|
||
title="Facebook"
|
||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#1877f2] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#1877f2]">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||
<path d="M24 12.073c0-6.627-5.373-12-12-12S0 5.446 0 12.073C0 18.062 4.388 23.027 10.125 23.927v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||
</svg>
|
||
</a>
|
||
|
||
{/* Reddit */}
|
||
<a
|
||
href={`https://www.reddit.com/submit?url=${encodeURIComponent(articleUrl)}&title=${encodeURIComponent(article.title)}`}
|
||
target="_blank" rel="noopener noreferrer"
|
||
aria-label="Share on Reddit"
|
||
title="Reddit"
|
||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#ff4500] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#ff4500]">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||
</svg>
|
||
</a>
|
||
|
||
{/* WhatsApp */}
|
||
<a
|
||
href={`https://api.whatsapp.com/send?text=${encodeURIComponent(article.title + ' ' + articleUrl)}`}
|
||
target="_blank" rel="noopener noreferrer"
|
||
aria-label="Share on WhatsApp"
|
||
title="WhatsApp"
|
||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#25d366] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#25d366]">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||
<path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.945C.16 5.335 5.495 0 12.05 0a11.817 11.817 0 0 1 8.413 3.488 11.824 11.824 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.711.306 1.265.489 1.697.626.713.226 1.362.194 1.875.118.572-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413z"/>
|
||
</svg>
|
||
</a>
|
||
|
||
{/* Copy Link */}
|
||
<button
|
||
onClick={async () => {
|
||
try { await navigator.clipboard.writeText(articleUrl); }
|
||
catch { /* swallow */ }
|
||
}}
|
||
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]">
|
||
<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"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<span className="w-px h-5 bg-[#252525] mx-1" aria-hidden="true" />
|
||
|
||
{/* Save Button */}
|
||
<button
|
||
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]">
|
||
{savingState === "saving" ?"Saving..."
|
||
: savingState === "removing" ?"Removing..."
|
||
: isSaved
|
||
? "✓ Saved" :"Save"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Featured Image */}
|
||
<div className="mb-8 overflow-hidden rounded-sm">
|
||
<AppImage
|
||
src={article.image}
|
||
alt={article.alt}
|
||
width={800}
|
||
height={450}
|
||
className="w-full h-auto object-cover"
|
||
priority
|
||
/>
|
||
</div>
|
||
|
||
{/* Article Body — paragraphs interleaved with rotating 300×250 ads every 4 paragraphs */}
|
||
<ArticleBody
|
||
html={article.content}
|
||
everyN={4}
|
||
className="bb-article-prose prose prose-invert max-w-none mb-8"
|
||
/>
|
||
|
||
{/* Tags */}
|
||
{article.tags && article.tags.length > 0 && (
|
||
<div className="py-6 border-t border-[#222]">
|
||
<p className="font-body text-xs text-[#555] uppercase tracking-wider mb-3">Tags</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{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">
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Email Share inline CTA */}
|
||
<div className="py-6 border-t border-[#222]">
|
||
<div className="flex items-center justify-between">
|
||
<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]">
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
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>
|
||
Share via Email
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Back navigation */}
|
||
<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]">
|
||
<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 News
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Article comments — sign-in gated for posting + voting,
|
||
public for reading. AI personas participate (tagged). */}
|
||
<ArticleComments articleSlug={article.slug} />
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<aside className="lg:col-span-4 space-y-6">
|
||
{/* Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
|
||
<SidebarAdStack />
|
||
|
||
{/* Companies referenced in the article body */}
|
||
{companiesInStory}
|
||
|
||
{/* 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]">
|
||
Related Articles
|
||
</h3>
|
||
<div className="space-y-4">
|
||
{relatedArticles.slice(0, 3).map((related) => (
|
||
<Link
|
||
key={related.slug}
|
||
href={`/news/${related.slug}`}
|
||
className="flex gap-3 group">
|
||
<div className="flex-shrink-0 w-[80px] h-[55px] relative overflow-hidden rounded-sm">
|
||
<AppImage
|
||
src={related.image}
|
||
alt={related.alt}
|
||
fill
|
||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||
sizes="80px"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[#3b82f6] 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">
|
||
{related.title}
|
||
</p>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</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-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||
Related Forum Posts
|
||
</h3>
|
||
<ul className="space-y-3.5">
|
||
{relatedForumThreads.map((t) => (
|
||
<li key={t.id}>
|
||
<Link href={`/forum/thread/${t.id}`} className="group block">
|
||
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
|
||
{t.title}
|
||
</p>
|
||
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
|
||
{t.category_name && (
|
||
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
|
||
{t.category_name}
|
||
</span>
|
||
)}
|
||
<span>·</span>
|
||
<span>{t.reply_count} {t.reply_count === 1 ? "reply" : "replies"}</span>
|
||
{t.vote_score > 0 && <><span>·</span><span>▲ {t.vote_score}</span></>}
|
||
</p>
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<Link
|
||
href="/forum"
|
||
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
|
||
>
|
||
Browse all forum threads →
|
||
</Link>
|
||
</div>
|
||
)}
|
||
|
||
{/* Newsletter Signup */}
|
||
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
|
||
<h3 className="font-heading text-[#e0e0e0] font-bold mb-2">Stay Updated</h3>
|
||
<p className="font-body text-[#777] text-xs mb-4">
|
||
Get the latest broadcast engineering news delivered to your inbox.
|
||
</p>
|
||
<Link
|
||
href="/home-page#newsletter"
|
||
className="btn-subscribe text-xs py-2 px-4 inline-block w-full text-center">
|
||
Subscribe Free
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Ad placeholder */}
|
||
</aside>
|
||
</div>
|
||
</article>
|
||
|
||
{/* Bottom leaderboard */}
|
||
<div className="max-w-container mx-auto px-4 pb-8">
|
||
<AdSlot zone="article-bottom" size="728x90" />
|
||
</div>
|
||
|
||
{/* Related Articles Carousel */}
|
||
{relatedArticles.length > 0 && (
|
||
<section className="border-t border-[#1a1a1a] bg-[#0d0d0d] py-10">
|
||
<div className="max-w-container mx-auto px-4">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<span className="section-label">More News</span>
|
||
<div className="h-px w-16 bg-[#2a2a2a]" />
|
||
</div>
|
||
{relatedArticles.length > CARDS_VISIBLE && (
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
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]">
|
||
<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>
|
||
</button>
|
||
<button
|
||
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]">
|
||
<svg
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
aria-hidden="true">
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Carousel track */}
|
||
<div className="overflow-hidden">
|
||
<div
|
||
className="flex gap-5 transition-transform duration-300 ease-in-out"
|
||
style={{
|
||
transform: `translateX(calc(-${carouselIndex} * (100% / ${CARDS_VISIBLE} + 20px / ${CARDS_VISIBLE})))`,
|
||
}}>
|
||
{relatedArticles.map((related) => (
|
||
<Link
|
||
key={related.slug}
|
||
href={`/news/${related.slug}`}
|
||
className="group flex-shrink-0 w-[calc(33.333%-14px)]"
|
||
style={{ minWidth: "calc(33.333% - 14px)" }}>
|
||
<div className="relative overflow-hidden rounded-sm mb-3 aspect-[16/9]">
|
||
<AppImage
|
||
src={related.image}
|
||
alt={related.alt}
|
||
fill
|
||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||
sizes="(max-width: 768px) 100vw, 33vw"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-1.5">
|
||
<span className="text-[#3b82f6] 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">
|
||
{related.title}
|
||
</h3>
|
||
<p className="text-[#666] font-body text-xs line-clamp-2 leading-relaxed">
|
||
{related.excerpt}
|
||
</p>
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<span className="text-[#555] font-body text-[11px]">By {related.author}</span>
|
||
<span className="text-[#444] text-[10px]">·</span>
|
||
<span className="text-[#555] font-body text-[11px]">{related.readTime}</span>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dot indicators */}
|
||
{relatedArticles.length > CARDS_VISIBLE && (
|
||
<div className="flex justify-center gap-1.5 mt-6">
|
||
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
|
||
<button
|
||
key={i}
|
||
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]"
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Email Share Modal */}
|
||
{emailShareOpen && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Share article via email">
|
||
{/* Backdrop */}
|
||
<div
|
||
className="absolute inset-0 bg-black/70"
|
||
onClick={() => {
|
||
setEmailShareOpen(false);
|
||
setEmailStatus("idle");
|
||
}}
|
||
/>
|
||
{/* Modal */}
|
||
<div className="relative bg-[#111] border border-[#2a2a2a] rounded-sm w-full max-w-md p-6 shadow-2xl">
|
||
<button
|
||
onClick={() => {
|
||
setEmailShareOpen(false);
|
||
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]">
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
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" />
|
||
</svg>
|
||
</button>
|
||
|
||
<div className="flex items-center gap-3 mb-5">
|
||
<div className="w-9 h-9 rounded-sm bg-[#1a2a3a] border border-[#1e3a5f] flex items-center justify-center flex-shrink-0">
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="#3b82f6"
|
||
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>
|
||
</div>
|
||
<div>
|
||
<h2 className="font-heading text-[#e0e0e0] font-bold text-base">Share via Email</h2>
|
||
<p className="font-body text-[#666] text-xs mt-0.5">Send this article to a colleague</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Article preview */}
|
||
<div className="bg-[#0d0d0d] border border-[#1a1a1a] rounded-sm p-3 mb-5">
|
||
<p className="font-body text-[#888] text-[10px] uppercase tracking-wider mb-1">
|
||
{article.category}
|
||
</p>
|
||
<p className="font-heading text-[#e0e0e0] text-sm font-bold line-clamp-2 leading-snug">
|
||
{article.title}
|
||
</p>
|
||
</div>
|
||
|
||
{emailStatus === "success" ? (
|
||
<div className="text-center py-6">
|
||
<div className="w-12 h-12 rounded-full bg-[#0d2a1a] border border-[#1a5a2a] flex items-center justify-center mx-auto mb-3">
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="#22c55e"
|
||
strokeWidth="2.5"
|
||
aria-hidden="true">
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
</div>
|
||
<p className="font-heading text-[#e0e0e0] font-bold mb-1">Email Sent!</p>
|
||
<p className="font-body text-[#777] text-sm">The article has been shared successfully.</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<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>
|
||
</label>
|
||
<input
|
||
id="email-to"
|
||
type="email"
|
||
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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label
|
||
htmlFor="email-note"
|
||
className="block font-body text-xs text-[#888] uppercase tracking-wider mb-1.5">
|
||
Personal Note <span className="text-[#555]">(optional)</span>
|
||
</label>
|
||
<textarea
|
||
id="email-note"
|
||
value={emailNote}
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
{emailStatus === "error" && (
|
||
<p className="text-xs font-body text-red-400">
|
||
Failed to send email. Please try again.
|
||
</p>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleEmailShare}
|
||
disabled={emailSending || !emailTo.trim()}
|
||
className="w-full btn-subscribe py-2.5 text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||
{emailSending ? (
|
||
<>
|
||
<svg
|
||
className="animate-spin"
|
||
width="14"
|
||
height="14"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
aria-hidden="true">
|
||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||
</svg>
|
||
Sending...
|
||
</>
|
||
) : (
|
||
"Send Article"
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Toast Notification */}
|
||
{saveToast && (
|
||
<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">
|
||
{saveToast}
|
||
</div>
|
||
)}
|
||
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|