initial commit: rocket.new export of broadcastbeat
This commit is contained in:
782
src/app/news/[slug]/NewsArticleDetailClient.tsx
Normal file
782
src/app/news/[slug]/NewsArticleDetailClient.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
"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 { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/sampleArticles";
|
||||
|
||||
interface NewsArticleDetailClientProps {
|
||||
article: Article;
|
||||
relatedArticles: Article[];
|
||||
}
|
||||
|
||||
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,
|
||||
}: 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://broadcastb5322.builtwithrocket.new"}/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://broadcastb5322.builtwithrocket.new/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.date}</span>
|
||||
<span className="text-xs text-[#555]">·</span>
|
||||
<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>
|
||||
<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-3">
|
||||
{/* Email Share Button */}
|
||||
<button
|
||||
onClick={() => setEmailShareOpen(true)}
|
||||
aria-label="Share via email"
|
||||
className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[#888] hover:text-[#3b82f6] transition-colors 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">
|
||||
<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
|
||||
</button>
|
||||
|
||||
{/* 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 */}
|
||||
<div
|
||||
className="prose prose-invert max-w-none mb-8 font-body text-[#aaa] leading-relaxed
|
||||
[&_h2]:font-heading [&_h2]:text-[#e0e0e0] [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mt-8 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:border-[#222] [&_h2]:pb-2
|
||||
[&_p]:mb-4 [&_p]:leading-relaxed
|
||||
[&_ul]:mb-4 [&_ul]:pl-5 [&_ul]:space-y-2
|
||||
[&_li]:text-[#aaa]
|
||||
[&_strong]:text-[#e0e0e0] [&_strong]:font-bold
|
||||
[&_a]:text-[#3b82f6] [&_a]:hover:underline"
|
||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:col-span-4 space-y-6">
|
||||
{/* 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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="ad-placeholder w-full h-[250px] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-[#444] text-xs font-body">Advertisement</p>
|
||||
<p className="text-[#333] text-[10px] font-body mt-1">300×250</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* 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>
|
||||
<span className="text-[#444] text-[10px]">·</span>
|
||||
<span className="text-[#555] font-body text-[11px]">{related.date}</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>
|
||||
);
|
||||
}
|
||||
106
src/app/news/[slug]/page.tsx
Normal file
106
src/app/news/[slug]/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { sampleArticles, getRelatedArticles } from "@/lib/articles/sampleArticles";
|
||||
import NewsArticleDetailClient from "./NewsArticleDetailClient";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const article = sampleArticles.find((a) => a.slug === slug && a.section === "news");
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
title: "Article Not Found",
|
||||
description: "The article you are looking for does not exist.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${article.title} — BroadcastBeat`,
|
||||
description: article.excerpt,
|
||||
alternates: {
|
||||
canonical: `/news/${article.slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
url: `/news/${article.slug}`,
|
||||
images: [
|
||||
{
|
||||
url: article.image,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: article.alt,
|
||||
type: "image/jpeg",
|
||||
},
|
||||
],
|
||||
authors: [article.author],
|
||||
publishedTime: article.date,
|
||||
tags: article.tags,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: article.title,
|
||||
description: article.excerpt,
|
||||
images: [article.image],
|
||||
creator: "@BroadcastBeat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return sampleArticles
|
||||
.filter((a) => a.section === "news")
|
||||
.map((article) => ({ slug: article.slug }));
|
||||
}
|
||||
|
||||
export default async function NewsArticlePage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const article = sampleArticles.find((a) => a.slug === slug && a.section === "news");
|
||||
|
||||
if (!article) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const related = getRelatedArticles(slug, 6);
|
||||
|
||||
const articleSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "NewsArticle",
|
||||
headline: article.title,
|
||||
description: article.excerpt,
|
||||
image: article.image,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: article.author,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "BroadcastBeat",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: "https://broadcastb5322.builtwithrocket.new/assets/images/logo.png",
|
||||
},
|
||||
},
|
||||
datePublished: article.date,
|
||||
dateModified: new Date().toISOString(),
|
||||
mainEntityOfPage: {
|
||||
"@type": "WebPage",
|
||||
"@id": `https://broadcastb5322.builtwithrocket.new/news/${article.slug}`,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<NewsArticleDetailClient article={article} relatedArticles={related} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
295
src/app/news/page.tsx
Normal file
295
src/app/news/page.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import { getArticlesBySection } from "@/lib/articles/sampleArticles";
|
||||
import AISuggestedArticles from "@/components/AISuggestedArticles";
|
||||
|
||||
const CATEGORIES = ["All", "Cloud", "AI", "IP Workflows", "Live Production", "Streaming", "Audio", "Cameras", "NAB 2026", "EAS", "Sports Broadcasting", "Ad Tech"];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "newest", label: "Newest First" },
|
||||
{ value: "oldest", label: "Oldest First" },
|
||||
{ value: "az", label: "A → Z" },
|
||||
{ value: "za", label: "Z → A" },
|
||||
];
|
||||
|
||||
function parseArticleDate(dateStr: string): Date {
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
export default function NewsPage() {
|
||||
const allArticles = getArticlesBySection("news");
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [sortBy, setSortBy] = useState("newest");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = [...allArticles];
|
||||
|
||||
// Search filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
a.excerpt.toLowerCase().includes(q) ||
|
||||
a.author.toLowerCase().includes(q) ||
|
||||
a.tags.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (activeCategory !== "All") {
|
||||
result = result.filter((a) =>
|
||||
a.tags.some((t) => t.toLowerCase() === activeCategory.toLowerCase()) ||
|
||||
a.category.toLowerCase().includes(activeCategory.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (dateFrom) {
|
||||
const from = new Date(dateFrom);
|
||||
result = result.filter((a) => parseArticleDate(a.date) >= from);
|
||||
}
|
||||
if (dateTo) {
|
||||
const to = new Date(dateTo);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
result = result.filter((a) => parseArticleDate(a.date) <= to);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
result.sort((a, b) => {
|
||||
if (sortBy === "newest") return parseArticleDate(b.date).getTime() - parseArticleDate(a.date).getTime();
|
||||
if (sortBy === "oldest") return parseArticleDate(a.date).getTime() - parseArticleDate(b.date).getTime();
|
||||
if (sortBy === "az") return a.title.localeCompare(b.title);
|
||||
if (sortBy === "za") return b.title.localeCompare(a.title);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [allArticles, search, activeCategory, dateFrom, dateTo, sortBy]);
|
||||
|
||||
const hasActiveFilters = search || activeCategory !== "All" || dateFrom || dateTo || sortBy !== "newest";
|
||||
|
||||
function clearFilters() {
|
||||
setSearch("");
|
||||
setActiveCategory("All");
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
setSortBy("newest");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
{/* DO NOT OVERRIDE — News page hero header */}
|
||||
<div className="bg-[#111] border-b border-[#222] py-8">
|
||||
<div className="max-w-container mx-auto px-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="section-label">News</span>
|
||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
||||
</div>
|
||||
<h1 className="font-heading text-white text-3xl font-bold">Latest Broadcast News</h1>
|
||||
<p className="text-[#777] font-body text-sm mt-2">Breaking news and industry updates from the world of broadcast engineering</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Bar */}
|
||||
<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">
|
||||
{/* 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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#555] hover:text-[#e0e0e0] transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
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]"
|
||||
title="From date"
|
||||
/>
|
||||
<span className="text-[#444] text-xs">–</span>
|
||||
<input
|
||||
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]"
|
||||
title="To date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<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"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-[#555] pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Category chips */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[#555] text-[10px] font-body uppercase tracking-wider shrink-0">Topics:</span>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
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]"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="ml-auto text-[11px] font-body text-[#555] hover:text-[#e0e0e0] underline transition-colors shrink-0"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="max-w-container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Article list */}
|
||||
<div className="lg:col-span-8">
|
||||
{/* Results count */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-[#555] font-body text-xs">
|
||||
{filtered.length === allArticles.length
|
||||
? `${allArticles.length} articles`
|
||||
: `${filtered.length} of ${allArticles.length} articles`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{filtered.map((article) => (
|
||||
<Link
|
||||
key={article.slug}
|
||||
href={`/news/${article.slug}`}
|
||||
className="flex gap-4 py-5 border-b border-[#222] group hover:bg-[#111] transition-colors px-2 -mx-2">
|
||||
<div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm">
|
||||
<AppImage
|
||||
src={article.image}
|
||||
alt={article.alt}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
sizes="160px"
|
||||
/>
|
||||
</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}</span>
|
||||
<span className="text-[#444] text-[10px]">·</span>
|
||||
<span className="text-[#555] font-body text-[11px]">{article.date}</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">
|
||||
{article.title}
|
||||
</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}</span>
|
||||
<span className="text-[#444] text-[10px]">·</span>
|
||||
<span className="text-[#555] font-body text-xs">{article.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:col-span-4 space-y-6">
|
||||
{/* AI Suggested Articles */}
|
||||
<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]">
|
||||
Popular Tags
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["IP Workflows", "Cloud", "AI", "Live Production", "Streaming", "NAB 2026", "Audio", "Cameras"].map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
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]"
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user