initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,959 @@
"use client";
import React, { useState, useRef, useId, useEffect, useCallback } from "react";
import AppImage from "@/components/ui/AppImage";
import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons";
import Link from "next/link";
interface ArticleItem {
title: string;
excerpt: string;
category: string;
date: string;
author: string;
authorSlug: string;
slug: string;
image: string;
alt: string;
source: "live" | "imported";
externalUrl?: string;
}
const staticArticles: ArticleItem[] = [
{
title: "Telestream Expands Its Cloud Services with the Introduction of UP",
excerpt: "New cloud-native platform extends Telestream Cloud Services to support Global Ingest, automation, review, and real-time monitoring across hybrid and distributed production environments.",
category: "BROADCAST",
date: "March 11, 2026",
author: "Staff Reporter",
authorSlug: "staff-reporter",
slug: "telestream-expands-cloud-services-up",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_11ff3d2ff-1773287949660.png",
alt: "Cloud services infrastructure for broadcast production environments",
source: "live"
},
{
title: "Operative Launches AOS Configuration for Digital-First Monetization",
excerpt: "Enterprise-grade intelligent ad management built to scale digital-first revenue across every channel — from linear to streaming and everything in between.",
category: "BROADCAST",
date: "March 11, 2026",
author: "James Whitfield",
authorSlug: "james-whitfield",
slug: "operative-launches-aos-configuration",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1805d1575-1773287951103.png",
alt: "Digital monetization analytics dashboard for broadcast advertising",
source: "live"
},
{
title: "Calrec Redefines Broadcast Workflows at NAB 2026 with its Most Powerful Audio Lineup Yet",
excerpt: "The broadcast industry is going through a rapid evolution that's signalling a more collaborative and location-independent future for live production.",
category: "BROADCAST",
date: "March 10, 2026",
author: "Sarah Chen",
authorSlug: "sarah-chen",
slug: "calrec-redefines-broadcast-workflows-nab-2026",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_10468e182-1773287953747.png",
alt: "Calrec audio mixing console for broadcast production at NAB 2026",
source: "live"
},
{
title: "Ease Live Powers Interactive Premier Padel Experiences on Red Bull TV as Viewership Surges 30%",
excerpt: "Real-time fan engagement enhances viewing and accelerates growth of the world's fastest-growing racket sport across digital broadcast platforms.",
category: "BROADCAST",
date: "March 10, 2026",
author: "Marcus Rivera",
authorSlug: "marcus-rivera",
slug: "ease-live-premier-padel-red-bull-tv",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1747232a3-1773287951222.png",
alt: "Interactive sports broadcast experience on streaming platform",
source: "live"
},
{
title: "From Metadata to Meaning: Why Semantic Intelligence Is Media's Next Competitive Advantage",
excerpt: "Strategic conversations about content performance and audience intelligence are shifting the way media organizations approach their metadata strategies.",
category: "FEATURED",
date: "March 9, 2026",
author: "Elena Vasquez",
authorSlug: "elena-vasquez",
slug: "from-metadata-to-meaning-semantic-intelligence",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1c523ae5f-1773287954372.png",
alt: "Media metadata intelligence and content analytics visualization",
source: "live"
},
{
title: "Mediagenix Title Management Accelerates Content Monetization Through Advanced Semantic Intelligence",
excerpt: "Advancing Semantic Intelligence capabilities to help studios and networks unlock greater value from their content libraries and distribution pipelines.",
category: "POST PRODUCTION",
date: "March 9, 2026",
author: "David Park",
authorSlug: "staff-reporter",
slug: "mediagenix-title-management",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_19f6e3fe6-1769744394519.png",
alt: "Content management system for media title management and monetization",
source: "live"
},
{
title: "Emergent Launches Fusion: The 'Interactive Anything' Platform Transforming Data-Driven Storytelling",
excerpt: "No-code application builder designed to transform complex live data into immersive interactive storytelling experiences for broadcasters and enterprises.",
category: "BROADCAST",
date: "March 8, 2026",
author: "Staff Reporter",
authorSlug: "staff-reporter",
slug: "emergent-launches-fusion-platform",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_188669b2b-1773287951738.png",
alt: "Interactive data storytelling platform for broadcast and enterprise use",
source: "live"
},
{
title: "KOKUSAI DENKI Electric America Welcomes Mondae Hott as Regional Sales Manager, Northeast",
excerpt: "Industry veteran joins the team to drive growth across the northeastern United States broadcast market.",
category: "BROADCAST",
date: "March 8, 2026",
author: "Lisa Thompson",
authorSlug: "staff-reporter",
slug: "kokusai-denki-mondae-hott",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1b072efa4-1772991129883.png",
alt: "Professional broadcast industry executive portrait in office setting",
source: "live"
},
{
title: "Matrox Video Enables the Next Era of Software-Defined Media Production at NAB 2026",
excerpt: "New solutions demonstrated at NAB 2026 showcase how software-defined workflows are reshaping the economics and flexibility of professional media production.",
category: "BROADCAST",
date: "March 7, 2026",
author: "Carlos Mendez",
authorSlug: "staff-reporter",
slug: "matrox-video-software-defined-nab-2026",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_17266f6cf-1773287953058.png",
alt: "Matrox video production hardware and software at NAB 2026 exhibition",
source: "live"
},
{
title: "GlobalM Introduces Distributed Video Gateway Architecture, Advancing Scalable Live IP Transport",
excerpt: "New distributed architecture addresses the growing demand for scalable, resilient live IP transport solutions in modern broadcast infrastructure.",
category: "BROADCAST",
date: "March 7, 2026",
author: "Staff Reporter",
authorSlug: "staff-reporter",
slug: "globalm-distributed-video-gateway",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1876db6da-1773287949606.png",
alt: "Network infrastructure for distributed video gateway IP transport system",
source: "live"
},
{
title: "Video is King: 2026 Iconik Media Stats Report Finds Video Consumes 64% of Storage Needs",
excerpt: "Annual media statistics report reveals video's growing dominance in enterprise storage requirements, with implications for media asset management strategies.",
category: "FEATURED",
date: "March 6, 2026",
author: "Rachel Kim",
authorSlug: "rachel-kim",
slug: "video-is-king-2026-iconik-media-stats",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_140568f68-1773287953452.png",
alt: "Video storage statistics and media asset management data visualization",
source: "live"
},
{
title: "QuickLink's Latest StudioEdge Models to Make North American Debut at NAB 2026",
excerpt: "New StudioEdge models bring enhanced remote production capabilities and simplified IP workflows to North American broadcast professionals.",
category: "BROADCAST",
date: "March 6, 2026",
author: "Tom Bradley",
authorSlug: "staff-reporter",
slug: "quicklink-studioedge-nab-2026",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_19c4ba922-1773287953682.png",
alt: "QuickLink StudioEdge broadcast production equipment at NAB exhibition",
source: "live"
},
{
title: "Cost Savings, Scalability, and Smarter Monetization: Viaccess-Orca Brings Efficiency to NAB 2026",
excerpt: "Purpose-driven solutions designed to help broadcasters reduce operational costs while improving content monetization across all distribution channels.",
category: "BROADCAST",
date: "March 5, 2026",
author: "Staff Reporter",
authorSlug: "staff-reporter",
slug: "viaccess-orca-nab-2026",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_12c71bf4d-1768883320639.png",
alt: "Broadcast technology monetization solutions at NAB Show 2026",
source: "live"
},
{
title: "Digital Alert Systems Unveils Version 6 Software for DASDEC-III Platforms",
excerpt: "Major software update brings enhanced EAS capabilities, improved user interface, and expanded integration options for emergency alerting systems.",
category: "BROADCAST",
date: "March 5, 2026",
author: "Nina Okafor",
authorSlug: "staff-reporter",
slug: "digital-alert-systems-dasdec-iii-v6",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_116c1855d-1773287954967.png",
alt: "Emergency alert system DASDEC-III platform software interface",
source: "live"
},
{
title: "Utah Scientific Expands Technology Partner Program With Integrations From Audinate, Bitfocus, and Skaarhoj",
excerpt: "New partnerships extend the ecosystem of compatible solutions for Utah Scientific routing and control systems in professional broadcast facilities.",
category: "BROADCAST",
date: "March 4, 2026",
author: "Staff Reporter",
authorSlug: "staff-reporter",
slug: "utah-scientific-partner-program",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1ea12f02f-1768375990494.png",
alt: "Broadcast routing and control system technology partner integration",
source: "live"
}
];
const sidebarAds = [
{ src: "https://images.unsplash.com/photo-1611532736597-de2d4265fba3?w=300&h=600&fit=crop", alt: "Blackmagic Design advertisement 300x600", label: "Blackmagic", size: "300x600" },
{ src: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=300&h=250&fit=crop", alt: "Zixi broadcast technology advertisement 300x250", label: "Zixi", size: "300x250" },
{ src: "https://www.broadcastbeat.com/wp-content/uploads/728-x-90-pxEN.gif", alt: "LiveU live production solutions advertisement 300x250", label: "LiveU", size: "300x250" },
{ src: "https://images.unsplash.com/photo-1563986768609-322da13575f3?w=300&h=250&fit=crop", alt: "TelyCam broadcast camera advertisement 300x250", label: "TelyCam", size: "300x250" },
{ src: "https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?w=300&h=250&fit=crop", alt: "AJA Video Systems advertisement 300x250", label: "AJA", size: "300x250" },
{ src: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=300&h=250&fit=crop", alt: "Studio Suite production management advertisement 300x250", label: "Studio Suite", size: "300x250" }
];
const ALL_CATEGORIES = ["All", "BROADCAST", "FEATURED", "POST PRODUCTION", "ANIMATION"];
const SOURCE_FILTERS = ["All", "Live", "Imported"] as const;
type SourceFilter = typeof SOURCE_FILTERS[number];
type FeedMode = "pagination" | "infinite";
type SortOrder = "recent" | "trending" | "most-commented";
const ARTICLES_PER_PAGE = 15;
// Derive unique authors from all articles (static + imported merged at runtime)
function getUniqueAuthors(articles: ArticleItem[]): string[] {
const seen = new Set<string>();
const authors: string[] = [];
for (const a of articles) {
if (a.author && !seen.has(a.author)) {
seen.add(a.author);
authors.push(a.author);
}
}
return authors.sort();
}
// Parse "Month DD, YYYY" → Date object for comparison
function parseArticleDate(dateStr: string): Date | null {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d;
}
function SourceBadge({ source }: { source: "live" | "imported" }) {
if (source === "imported") {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-[9px] font-bold uppercase tracking-wide bg-amber-500/15 text-amber-400 border border-amber-500/30">
<span className="w-1 h-1 rounded-full bg-amber-400 inline-block" />
Imported
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-[9px] font-bold uppercase tracking-wide bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
<span className="w-1 h-1 rounded-full bg-emerald-400 inline-block" />
Live
</span>
);
}
export default function ArticleFeed() {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const [activeCategory, setActiveCategory] = useState("All");
const [sourceFilter, setSourceFilter] = useState<SourceFilter>("All");
const [wpPosts, setWpPosts] = useState<ArticleItem[]>([]);
const [loadingWp, setLoadingWp] = useState(true);
const [feedMode, setFeedMode] = useState<FeedMode>("pagination");
const [infiniteCount, setInfiniteCount] = useState(ARTICLES_PER_PAGE);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [sortOrder, setSortOrder] = useState<SortOrder>("recent");
// Advanced filters
const [showAdvanced, setShowAdvanced] = useState(false);
const [authorFilter, setAuthorFilter] = useState("All");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const filterRefs = useRef<(HTMLButtonElement | null)[]>([]);
const sentinelRef = useRef<HTMLDivElement>(null);
const feedId = useId();
// Load preference from localStorage
useEffect(() => {
try {
const saved = localStorage.getItem("latest-feed-mode");
if (saved === "infinite" || saved === "pagination") {
setFeedMode(saved);
}
} catch {
// ignore
}
}, []);
const handleFeedModeChange = (mode: FeedMode) => {
setFeedMode(mode);
setPage(1);
setInfiniteCount(ARTICLES_PER_PAGE);
try {
localStorage.setItem("latest-feed-mode", mode);
} catch {
// ignore
}
};
// Merge: interleave imported posts with static (imported first by recency, then static)
const allArticles: ArticleItem[] = [...wpPosts, ...staticArticles];
const uniqueAuthors = getUniqueAuthors(allArticles);
const hasAdvancedActive = authorFilter !== "All" || dateFrom !== "" || dateTo !== "";
const clearAllFilters = () => {
setSearchQuery("");
setActiveCategory("All");
setSourceFilter("All");
setAuthorFilter("All");
setDateFrom("");
setDateTo("");
setPage(1);
searchRef.current?.focus();
};
// Filter articles
const filtered = allArticles.filter((a) => {
const matchCat = activeCategory === "All" || a.category === activeCategory;
const matchSource =
sourceFilter === "All" ||
(sourceFilter === "Imported" && a.source === "imported") ||
(sourceFilter === "Live" && a.source === "live");
const q = searchQuery.toLowerCase();
const matchSearch = !q || a.title.toLowerCase().includes(q) || a.excerpt.toLowerCase().includes(q) || a.author.toLowerCase().includes(q);
const matchAuthor = authorFilter === "All" || a.author === authorFilter;
// Date range filtering
let matchDate = true;
if (dateFrom || dateTo) {
const articleDate = parseArticleDate(a.date);
if (articleDate) {
if (dateFrom) {
const from = new Date(dateFrom);
if (articleDate < from) matchDate = false;
}
if (dateTo && matchDate) {
const to = new Date(dateTo);
to.setHours(23, 59, 59, 999);
if (articleDate > to) matchDate = false;
}
}
}
return matchCat && matchSource && matchSearch && matchAuthor && matchDate;
});
// Sort filtered articles based on sortOrder
const sortedFiltered = [...filtered].sort((a, b) => {
if (sortOrder === "recent") {
const da = parseArticleDate(a.date);
const db = parseArticleDate(b.date);
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return db.getTime() - da.getTime();
}
if (sortOrder === "trending") {
// Trending: FEATURED category gets a boost, then by recency
const featuredBoost = (art: ArticleItem) => (art.category === "FEATURED" ? 2 : 0);
const importedBoost = (art: ArticleItem) => (art.source === "imported" ? 1 : 0);
const scoreA = featuredBoost(a) + importedBoost(a);
const scoreB = featuredBoost(b) + importedBoost(b);
if (scoreB !== scoreA) return scoreB - scoreA;
const da = parseArticleDate(a.date);
const db = parseArticleDate(b.date);
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return db.getTime() - da.getTime();
}
if (sortOrder === "most-commented") {
// Most Commented: use a deterministic hash of the title as a proxy comment count
const commentScore = (art: ArticleItem) => {
let hash = 0;
for (let i = 0; i < art.title.length; i++) {
hash = (hash * 31 + art.title.charCodeAt(i)) & 0xffff;
}
return hash % 200; // 0199 simulated comment count
};
return commentScore(b) - commentScore(a);
}
return 0;
});
const totalPages = Math.max(1, Math.ceil(sortedFiltered.length / ARTICLES_PER_PAGE));
const displayedArticles = feedMode === "infinite"
? sortedFiltered.slice(0, infiniteCount)
: sortedFiltered.slice((page - 1) * ARTICLES_PER_PAGE, page * ARTICLES_PER_PAGE);
const hasMore = feedMode === "infinite" && infiniteCount < sortedFiltered.length;
const importedCount = allArticles.filter((a) => a.source === "imported").length;
// Reset infinite count when filters change
useEffect(() => {
setInfiniteCount(ARTICLES_PER_PAGE);
}, [searchQuery, activeCategory, sourceFilter, authorFilter, dateFrom, dateTo, sortOrder]);
// Arrow key navigation for filter chips
const handleFilterKeyDown = (e: React.KeyboardEvent, index: number) => {
const items = filterRefs.current.filter(Boolean);
if (e.key === "ArrowRight") {
e.preventDefault();
items[(index + 1) % items.length]?.focus();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
items[(index - 1 + items.length) % items.length]?.focus();
} else if (e.key === "Home") {
e.preventDefault();
items[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
items[items.length - 1]?.focus();
}
};
const clearSearch = () => {
setSearchQuery("");
searchRef.current?.focus();
};
// Infinite scroll: IntersectionObserver on sentinel
const loadMore = useCallback(() => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
setTimeout(() => {
setInfiniteCount((prev) => Math.min(prev + ARTICLES_PER_PAGE, sortedFiltered.length));
setIsLoadingMore(false);
}, 400);
}, [isLoadingMore, hasMore, sortedFiltered.length]);
useEffect(() => {
if (feedMode !== "infinite") return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: "200px" }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [feedMode, loadMore]);
return (
<section className="max-w-container mx-auto px-4 py-4 md:py-6">
{/* Main layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-8">
{/* Main Feed */}
<div className="lg:col-span-8">
{/* Section header */}
<div className="flex items-center gap-3 mb-4 md:mb-5 flex-wrap">
<span className="section-label">The Latest</span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
{/* Sort dropdown */}
<div className="flex items-center gap-1.5">
<span className="font-body text-[10px] text-[#555] uppercase tracking-wide hidden sm:inline">Sort:</span>
<div className="relative">
<select
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]"
>
<option value="recent">Recent</option>
<option value="trending">Trending</option>
<option value="most-commented">Most Commented</option>
</select>
<svg className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-[#555]" width="9" height="9" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
{/* Feed mode toggle */}
<div className="flex items-center gap-1.5" role="group" aria-label="Feed display mode">
<span className="font-body text-[10px] text-[#555] uppercase tracking-wide hidden sm:inline">View:</span>
<div className="flex items-center bg-[#111] border border-[#2a2a2a] rounded-sm p-0.5">
<button
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"
}`}>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<rect x="1" y="1" width="10" height="2.5" rx="0.5" fill="currentColor" opacity="0.9"/>
<rect x="1" y="4.75" width="10" height="2.5" rx="0.5" fill="currentColor" opacity="0.6"/>
<rect x="1" y="8.5" width="6" height="2.5" rx="0.5" fill="currentColor" opacity="0.4"/>
</svg>
Pages
</button>
<button
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"
}`}>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<rect x="1" y="1" width="10" height="2" rx="0.5" fill="currentColor" opacity="0.9"/>
<rect x="1" y="4" width="10" height="2" rx="0.5" fill="currentColor" opacity="0.7"/>
<rect x="1" y="7" width="10" height="2" rx="0.5" fill="currentColor" opacity="0.5"/>
<path d="M5 10.5 L6 11.5 L7 10.5" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" opacity="0.8"/>
</svg>
Scroll
</button>
</div>
</div>
<span className="font-body text-xs text-[#555]">{sortedFiltered.length?.toLocaleString()} article{sortedFiltered.length !== 1 ? "s" : ""}</span>
</div>
{/* Search + Filters */}
<div className="mb-4 space-y-3">
{/* Search bar row with Advanced Filters toggle */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<label htmlFor={`${feedId}-search`} className="sr-only">Search articles</label>
<SearchIcon
size={14}
strokeWidth={1.75}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#555] pointer-events-none"
aria-hidden="true"
/>
<input
id={`${feedId}-search`}
ref={searchRef}
type="search"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
placeholder="Search articles by title, topic, or author..."
aria-label="Search articles"
aria-controls={`${feedId}-results`}
className="search-input search-input-enhanced w-full pl-9 pr-9 py-2 text-sm"
/>
{searchQuery && (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="search-clear-btn right-2.5">
<CloseIcon size={11} />
</button>
)}
</div>
{/* Advanced filters toggle button */}
<button
type="button"
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 ${
showAdvanced || hasAdvancedActive
? "bg-[#3b82f6]/15 text-[#3b82f6] border-[#3b82f6]/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">
<path d="M1 3h10M3 6h6M5 9h2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
Filters
{hasAdvancedActive && (
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] inline-block ml-0.5" aria-label="Active filters" />
)}
</button>
</div>
{/* Advanced filters panel */}
{showAdvanced && (
<div
id={`${feedId}-advanced`}
className="bg-[#0d0d0d] border border-[#2a2a2a] rounded-sm p-3 space-y-3"
role="group"
aria-label="Advanced filters">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Author filter */}
<div className="space-y-1">
<label htmlFor={`${feedId}-author`} className="font-body text-[10px] text-[#555] uppercase tracking-wide block">
Author
</label>
<div className="relative">
<select
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]"
aria-label="Filter by author">
<option value="All">All Authors</option>
{uniqueAuthors.map((author) => (
<option key={author} value={author}>{author}</option>
))}
</select>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-[#555]" width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
{/* Category filter (moved into advanced panel as well) */}
<div className="space-y-1">
<label htmlFor={`${feedId}-category-select`} className="font-body text-[10px] text-[#555] uppercase tracking-wide block">
Category
</label>
<div className="relative">
<select
id={`${feedId}-category-select`}
value={activeCategory}
onChange={(e) => { setActiveCategory(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]"
aria-label="Filter by category">
{ALL_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{cat === "All" ? "All Categories" : cat}</option>
))}
</select>
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-[#555]" width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
{/* Date From */}
<div className="space-y-1">
<label htmlFor={`${feedId}-date-from`} className="font-body text-[10px] text-[#555] uppercase tracking-wide block">
Date From
</label>
<input
id={`${feedId}-date-from`}
type="date"
value={dateFrom}
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
max={dateTo || undefined}
className="w-full bg-[#111] border border-[#2a2a2a] text-[#ccc] font-body text-sm rounded-sm px-3 py-1.5 focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]/30 transition-colors [color-scheme:dark]"
aria-label="Filter articles from date"
/>
</div>
{/* Date To */}
<div className="space-y-1">
<label htmlFor={`${feedId}-date-to`} className="font-body text-[10px] text-[#555] uppercase tracking-wide block">
Date To
</label>
<input
id={`${feedId}-date-to`}
type="date"
value={dateTo}
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
min={dateFrom || undefined}
className="w-full bg-[#111] border border-[#2a2a2a] text-[#ccc] font-body text-sm rounded-sm px-3 py-1.5 focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]/30 transition-colors [color-scheme:dark]"
aria-label="Filter articles to date"
/>
</div>
</div>
{/* Source filter inside advanced panel */}
<div className="space-y-1">
<span className="font-body text-[10px] text-[#555] uppercase tracking-wide block">Source</span>
<div className="flex items-center gap-1 bg-[#111] border border-[#2a2a2a] rounded-sm p-0.5 w-fit" role="group" aria-label="Filter by source">
{SOURCE_FILTERS.map((sf) => {
const isActive = sourceFilter === sf;
return (
<button
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] ${
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}
{sf === "Imported" && importedCount > 0 && (
<span className="ml-1 text-[9px] opacity-70">({importedCount})</span>
)}
</button>
);
})}
</div>
{loadingWp && (
<span className="font-body text-[10px] text-[#555] italic">Loading imported posts</span>
)}
</div>
{/* Clear advanced filters */}
{hasAdvancedActive && (
<div className="flex justify-end pt-1 border-t border-[#1e1e1e]">
<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">
Clear advanced filters
</button>
</div>
)}
</div>
)}
{/* Category filter chips (always visible, quick access) */}
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label="Filter articles by category">
{ALL_CATEGORIES.map((cat, i) => {
const isActive = activeCategory === cat;
return (
<button
key={cat}
ref={(el) => { filterRefs.current[i] = el; }}
onClick={() => { setActiveCategory(cat); setPage(1); }}
onKeyDown={(e) => handleFilterKeyDown(e, i)}
aria-pressed={isActive}
tabIndex={isActive ? 0 : -1}
className={`filter-chip ${isActive ? "filter-chip-active" : ""} focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]`}>
{cat}
</button>
);
})}
</div>
{/* Results count */}
{(searchQuery || activeCategory !== "All" || sourceFilter !== "All" || authorFilter !== "All" || dateFrom || dateTo) && (
<p
id={`${feedId}-results`}
className="font-body text-xs text-[#666]"
aria-live="polite"
aria-atomic="true">
{sortedFiltered.length === 0
? "No articles found — "
: `${sortedFiltered.length} article${sortedFiltered.length !== 1 ? "s" : ""} found`}
{searchQuery ? ` for "${searchQuery}"` : ""}
{activeCategory !== "All" ? ` in ${activeCategory}` : ""}
{authorFilter !== "All" ? ` · by ${authorFilter}` : ""}
{sourceFilter !== "All" ? ` · ${sourceFilter} only` : ""}
{(dateFrom || dateTo) ? ` · ${dateFrom ? dateFrom : "any"}${dateTo ? dateTo : "any"}` : ""}
{sortedFiltered.length === 0 && (
<button
onClick={clearAllFilters}
className="ml-1 text-[#3b82f6] hover:underline focus:outline-none focus-visible:underline">
Clear filters
</button>
)}
</p>
)}
</div>
{/* Article list */}
{displayedArticles.length === 0 ? (
<div className="empty-state py-12" role="status">
<div className="empty-state-icon">
<SearchIcon size={48} strokeWidth={1} />
</div>
<p className="empty-state-title">No articles found</p>
<p className="empty-state-desc">
Try adjusting your search or selecting a different category.
</p>
<button
onClick={clearAllFilters}
className="mt-4 font-body text-sm text-[#3b82f6] hover:underline focus:outline-none focus-visible:underline">
Clear filters
</button>
</div>
) : (
<div className="divide-y divide-[#222]" id={`${feedId}-results`}>
{displayedArticles?.map((article, i) => {
const isImported = article.source === "imported";
const articleHref = isImported && article.externalUrl
? article.externalUrl
: `/articles/${article?.slug}`;
const linkProps = isImported ? { target: "_blank", rel: "noopener noreferrer" } : {};
return (
<article key={i} className="article-card flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2">
<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]"
tabIndex={-1}
aria-hidden="true"
>
<AppImage
src={article?.image}
alt={article?.alt}
width={190}
height={128}
className="object-cover w-full h-full"
sizes="(max-width: 640px) 100px, (max-width: 768px) 140px, 190px"
/>
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 md:gap-2 mb-1 md:mb-1.5 flex-wrap">
<span className="font-body text-[10px] font-bold text-[#3b82f6] uppercase tracking-wide">
{article?.category}
</span>
<span className="text-[#444] text-xs hidden sm:inline">|</span>
<span className="text-[#666] text-xs font-body hidden sm:inline">{article?.date}</span>
<SourceBadge source={article.source} />
</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">
<Link
href={articleHref}
{...linkProps}
className="focus:outline-none focus-visible:text-[#3b82f6] focus-visible:underline">
{article?.title}
</Link>
</h3>
<p className="font-body text-[#777] text-sm leading-relaxed line-clamp-2 mb-2 md:mb-2.5 hidden sm:block">
{article?.excerpt}
</p>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 hidden sm:flex">
<PersonIcon size={11} className="text-[#666] flex-shrink-0" />
<span className="font-body text-xs text-[#666]">
By {article?.author}
</span>
</div>
<Link
href={articleHref}
{...linkProps}
className="read-more flex-shrink-0 text-[11px] focus:outline-none focus-visible:underline"
aria-label={`Read more about ${article?.title}`}>
{isImported ? "View Original »" : "Read More »"}
</Link>
</div>
</div>
</article>
);
})}
</div>
)}
{/* Infinite scroll sentinel + loading indicator */}
{feedMode === "infinite" && (
<div ref={sentinelRef} className="mt-4">
{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" }} />
</div>
<span className="font-body text-xs text-[#555]">Loading more articles</span>
</div>
)}
{!hasMore && displayedArticles.length > 0 && !isLoadingMore && (
<div className="flex items-center gap-3 py-5 border-t border-[#222]">
<div className="flex-1 h-px bg-[#1e1e1e]" />
<span className="font-body text-[11px] text-[#444] uppercase tracking-wider">All {sortedFiltered.length} articles loaded</span>
<div className="flex-1 h-px bg-[#1e1e1e]" />
</div>
)}
</div>
)}
{/* Pagination */}
{feedMode === "pagination" && sortedFiltered.length > ARTICLES_PER_PAGE && (
<div className="mt-5 md:mt-6 flex items-center justify-between flex-wrap gap-3 border-t border-[#222] pt-4 md:pt-5">
<p className="font-body text-sm text-[#666]">
Page <span className="font-bold text-[#aaa]">{page}</span> of{" "}
<span className="font-bold text-[#aaa]">{totalPages?.toLocaleString()}</span>
<span className="text-[#555] ml-2 text-xs">({sortedFiltered.length} articles)</span>
</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]"
disabled={page === 1}
onClick={() => { setPage(Math.max(1, page - 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
aria-label="Previous page">
<ChevronLeftIcon size={12} />
Prev
</button>
{(() => {
const pages: (number | "...")[] = [];
if (totalPages <= 5) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push("...");
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i);
if (page < totalPages - 2) pages.push("...");
pages.push(totalPages);
}
return pages.map((p, idx) =>
p === "..." ? (
<span key={`ellipsis-${idx}`} className="font-body text-[#999] text-sm px-1" aria-hidden="true">...</span>
) : (
<button
key={p}
className={`page-btn focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${page === p ? "active" : ""}`}
onClick={() => { setPage(p as number); window.scrollTo({ top: 0, behavior: "smooth" }); }}
aria-label={`Page ${p}`}
aria-current={page === p ? "page" : undefined}>
{p}
</button>
)
);
})()}
<button
className="page-btn inline-flex items-center gap-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]"
disabled={page === totalPages}
onClick={() => { setPage(Math.min(totalPages, page + 1)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
aria-label="Next page">
Next
<ChevronRightIcon size={12} />
</button>
</div>
</div>
)}
</div>
{/* Sidebar */}
<aside className="lg:col-span-4" aria-label="Advertisements">
<div className="lg:sticky lg:top-24">
<div className="grid grid-cols-2 gap-3 lg:hidden">
{sidebarAds?.filter(ad => ad?.size !== "300x600")?.map((ad, i) => (
<a key={i} href="#" aria-label={`${ad?.label} advertisement`} className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
<div className="ad-placeholder overflow-hidden h-[120px] w-full relative">
<AppImage
src={ad?.src}
alt={ad?.alt}
fill
className="w-full h-full object-cover"
sizes="(max-width: 1024px) 50vw"
/>
<div className="absolute bottom-1 right-1 bg-black/40 text-white/60 font-body text-[9px] px-1 py-0.5">
AD · {ad?.label}
</div>
</div>
</a>
))}
</div>
<div className="hidden lg:flex lg:flex-col lg:space-y-4">
{sidebarAds?.map((ad, i) => (
<a key={i} href="#" aria-label={`${ad?.label} advertisement`} className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
<div
className={`ad-placeholder overflow-hidden ${ad?.size === "300x600" ? "h-[600px]" : "h-[250px]"}`}
style={{ width: "100%", maxWidth: 300 }}>
<AppImage
src={ad?.src}
alt={ad?.alt}
width={300}
height={ad?.size === "300x600" ? 600 : 250}
className="w-full h-full object-cover"
sizes="300px"
/>
<div className="absolute bottom-1 right-1 bg-black/40 text-white/60 font-body text-[9px] px-1 py-0.5">
AD · {ad?.label}
</div>
</div>
</a>
))}
</div>
</div>
</aside>
</div>
</section>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import AppImage from "@/components/ui/AppImage";
import { ArrowRightIcon } from "@/components/ui/Icons";
import Link from "next/link";
// Fixed hero — only changes when manually updated
const hero = {
title: "Sony BRC-AM7 + HXC-FZ90: Better Together in the Field",
category: "BROADCAST",
excerpt: "Sony's latest camera pairing delivers unprecedented flexibility for field production teams, combining 4K HDR capture with compact form factors designed for the realities of live broadcast.",
image: "https://www.broadcastbeat.com/wp-content/uploads/BRC-AM7_012-Mid.png",
alt: "Sony BRC-AM7 broadcast camera in field setting",
slug: "sony-brc-am7-hxc-fz90",
};
// Last 15 stories from the homepage feed — these rotate in the secondary cards
const rotatingStories = [
{
title: "Sony\'s BVM-HX1710: The Compact Powerhouse Changing How Broadcast & Post See Their Work",
category: "POST PRODUCTION",
image: "https://www.broadcastbeat.com/wp-content/uploads/bvmhx1710_3q_250131_02-copy-scaled.png",
alt: "Sony BVM-HX1710 broadcast monitor three-quarter view",
slug: "sony-bvm-hx1710",
},
{
title: "The Top 5 Challenges in MCRs & NOCs—And How to Stay Ahead",
category: "BROADCAST",
image: "https://www.broadcastbeat.com/wp-content/uploads/data-insight-ip-monitoring-1024x597.pngw3_.webp",
alt: "IP monitoring dashboard showing data insights for MCR operations",
slug: "top-5-challenges-mcrs-nocs",
},
{
title: "TESSERA SQ200: THE NEW PACE-SETTER IN LED PROCESSING",
category: "FEATURED",
image: "https://www.broadcastbeat.com/wp-content/uploads/Hero-Image_SQ200-Gen-3_Sep2025_CMYK.jpg",
alt: "Tessera SQ200 Gen 3 LED processor hero image",
slug: "tessera-sq200-led-processing",
},
{
title: "Backlight adds real-time collaboration to Iconik, AI-powered livestreams to Wildmoka at IBC2025",
category: "BROADCAST",
image: "https://www.broadcastbeat.com/wp-content/uploads/Iconik-scaled.jpeg",
alt: "Iconik media asset management platform interface",
slug: "backlight-iconik-wildmoka-ibc2025",
},
{
title: "Telestream Expands Its Cloud Services with the Introduction of UP",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_11ff3d2ff-1773287949660.png",
alt: "Cloud services infrastructure for broadcast production environments",
slug: "telestream-expands-cloud-services-up",
},
{
title: "Operative Launches AOS Configuration for Digital-First Monetization",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1805d1575-1773287951103.png",
alt: "Digital monetization analytics dashboard for broadcast advertising",
slug: "operative-launches-aos-configuration",
},
{
title: "Calrec Redefines Broadcast Workflows at NAB 2026 with its Most Powerful Audio Lineup Yet",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_10468e182-1773287953747.png",
alt: "Calrec audio mixing console for broadcast production at NAB 2026",
slug: "calrec-redefines-broadcast-workflows-nab-2026",
},
{
title: "Ease Live Powers Interactive Premier Padel Experiences on Red Bull TV as Viewership Surges 30%",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1747232a3-1773287951222.png",
alt: "Interactive sports broadcast experience on streaming platform",
slug: "ease-live-premier-padel-red-bull-tv",
},
{
title: "From Metadata to Meaning: Why Semantic Intelligence Is Media\'s Next Competitive Advantage",
category: "FEATURED",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1c523ae5f-1773287954372.png",
alt: "Media metadata intelligence and content analytics visualization",
slug: "from-metadata-to-meaning-semantic-intelligence",
},
{
title: "Mediagenix Title Management Accelerates Content Monetization Through Advanced Semantic Intelligence",
category: "POST PRODUCTION",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_19f6e3fe6-1769744394519.png",
alt: "Content management system for media title management and monetization",
slug: "mediagenix-title-management",
},
{
title: "Emergent Launches Fusion: The \'Interactive Anything\' Platform Transforming Data-Driven Storytelling",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_188669b2b-1773287951738.png",
alt: "Interactive data storytelling platform for broadcast and enterprise use",
slug: "emergent-launches-fusion-platform",
},
{
title: "Matrox Video Enables the Next Era of Software-Defined Media Production at NAB 2026",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_17266f6cf-1773287953058.png",
alt: "Matrox video production hardware and software at NAB 2026 exhibition",
slug: "matrox-video-software-defined-nab-2026",
},
{
title: "Video is King: 2026 Iconik Media Stats Report Finds Video Consumes 64% of Storage Needs",
category: "FEATURED",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_140568f68-1773287953452.png",
alt: "Video storage statistics and media asset management data visualization",
slug: "video-is-king-2026-iconik-media-stats",
},
{
title: "QuickLink\'s Latest StudioEdge Models to Make North American Debut at NAB 2026",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_19c4ba922-1773287953682.png",
alt: "QuickLink StudioEdge broadcast production equipment at NAB exhibition",
slug: "quicklink-studioedge-nab-2026",
},
{
title: "Utah Scientific Expands Technology Partner Program With Integrations From Audinate, Bitfocus, and Skaarhoj",
category: "BROADCAST",
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1ea12f02f-1768375990494.png",
alt: "Broadcast routing and control system technology partner integration",
slug: "utah-scientific-partner-program",
},
];
// Number of secondary cards visible at once
const CARDS_PER_PAGE = 4;
// Auto-rotate interval in ms
const AUTO_ROTATE_MS = 5000;
export default function FeaturedBento() {
// offset = index of first visible secondary card
const [offset, setOffset] = useState(0);
const [paused, setPaused] = useState(false);
const maxOffset = rotatingStories?.length - CARDS_PER_PAGE;
const goNext = useCallback(() => {
setOffset((prev) => (prev >= maxOffset ? 0 : prev + 1));
}, [maxOffset]);
const goPrev = useCallback(() => {
setOffset((prev) => (prev <= 0 ? maxOffset : prev - 1));
}, [maxOffset]);
// Auto-rotate
useEffect(() => {
if (paused) return;
const timer = setInterval(goNext, AUTO_ROTATE_MS);
return () => clearInterval(timer);
}, [paused, goNext]);
const visibleCards = rotatingStories?.slice(offset, offset + CARDS_PER_PAGE);
return (
<section className="max-w-container mx-auto px-4 py-4 md:py-6">
{/* Section header */}
<div className="flex items-center gap-3 mb-4 md:mb-5">
<span className="section-label">Featured</span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
{/* Navigation arrows */}
<div className="flex items-center gap-1.5">
<button
onClick={() => { goPrev(); setPaused(true); }}
aria-label="Previous stories"
className="w-7 h-7 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#666] transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button
onClick={() => { goNext(); setPaused(true); }}
aria-label="Next stories"
className="w-7 h-7 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#666] transition-colors rounded-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/* Position indicator */}
<span className="font-body text-[11px] text-[#555] ml-1">
{offset + 1}{Math.min(offset + CARDS_PER_PAGE, rotatingStories?.length)}/{rotatingStories?.length}
</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 md:gap-4">
{/* Hero Article — FIXED, only changes when manually updated */}
<Link
href={`/articles/${hero?.slug}`}
className="lg:col-span-7 img-zoom rounded-sm overflow-hidden relative group cursor-pointer block"
style={{ minHeight: 260 }}>
<div className="relative w-full h-[260px] sm:h-[320px] md:h-[380px] lg:h-full lg:min-h-[400px]">
<AppImage
src={hero?.image}
alt={hero?.alt}
fill
className="object-cover w-full h-full"
priority={true}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 100vw, 58vw"
/>
<div className="hero-overlay absolute inset-0" />
<div className="absolute inset-0 flex flex-col justify-end p-3 sm:p-4 md:p-5">
<span className="inline-block bg-[#3b82f6] text-white font-body text-[10px] font-bold px-2 py-0.5 tracking-wider uppercase mb-2 w-fit">
{hero?.category}
</span>
<h2 className="font-heading text-white text-lg sm:text-xl md:text-2xl font-bold leading-tight mb-2 max-w-xl">
{hero?.title}
</h2>
<p className="text-white/80 font-body text-sm leading-relaxed max-w-lg hidden sm:block mb-3">
{hero?.excerpt}
</p>
<span className="inline-flex items-center gap-1.5 text-white font-body font-bold text-xs uppercase tracking-wider group-hover:text-[#93c5fd] transition-colors">
Read More
<ArrowRightIcon size={12} />
</span>
</div>
</div>
</Link>
{/* Secondary 2x2 Grid — rotating stories */}
<div
className="lg:col-span-5 grid grid-cols-2 gap-2 md:gap-3"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}>
{visibleCards?.map((article, i) => (
<Link
key={`${article?.slug}-${offset}-${i}`}
href={`/articles/${article?.slug}`}
className="bg-[#1a1a1a] border border-[#2a2a2a] overflow-hidden group cursor-pointer card-accent-hover flex flex-col hover:border-[#3b82f6] transition-colors">
<div className="relative overflow-hidden img-zoom h-[90px] sm:h-[110px] md:h-[120px]">
<AppImage
src={article?.image}
alt={article?.alt}
fill
className="object-cover w-full h-full"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 50vw, 22vw"
/>
<div className="absolute top-0 left-0">
<span className="bg-[#3b82f6] text-white font-body text-[9px] font-bold px-1.5 py-0.5 tracking-wider uppercase">
{article?.category}
</span>
</div>
</div>
<div className="p-2 md:p-2.5 flex-1 flex flex-col justify-between">
<h3 className="font-heading text-[#e0e0e0] text-[12px] md:text-[13px] font-bold leading-snug line-clamp-3 group-hover:text-[#3b82f6] transition-colors duration-200 mb-1.5 md:mb-2">
{article?.title}
</h3>
<span className="read-more inline-block text-[11px]">Read More »</span>
</div>
</Link>
))}
</div>
</div>
{/* Dot indicators */}
<div className="flex justify-end items-center gap-1 mt-3">
{Array.from({ length: maxOffset + 1 })?.map((_, i) => (
<button
key={i}
onClick={() => { setOffset(i); setPaused(true); }}
aria-label={`Go to position ${i + 1}`}
className={`rounded-full transition-all focus:outline-none ${
i === offset
? "w-4 h-1.5 bg-[#3b82f6]"
: "w-1.5 h-1.5 bg-[#333] hover:bg-[#555]"
}`}
/>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,22 @@
import React from "react";
import AppImage from "@/components/ui/AppImage";
// DO NOT OVERRIDE — Leaderboard ad placeholder layout
export default function LeaderboardAd() {
return (
<div className="bg-ice-blue border-b border-border py-3">
<div className="max-w-container mx-auto px-4 flex justify-center">
{/* Leaderboard ad unit — 728x90 — stub links to advertise page */}
<a href="/advertise" aria-label="Advertisement — click to learn about advertising on BroadcastBeat" className="block">
<AppImage
src="https://img.rocket.new/generatedImages/rocket_gen_img_1bebfd3c7-1765090260606.png"
alt="BroadcastBeat sponsor advertisement banner 728x90"
width={728}
height={90}
className="max-w-full h-auto" />
</a>
</div>
</div>);
}

View File

@@ -0,0 +1,48 @@
"use client";
import React from "react";
import Link from "next/link";
// DO NOT OVERRIDE — NewsTicker headlines and animation
const headlines = [
{ text: "Calrec Redefines Broadcast Workflows at NAB 2026 with its Most Powerful Hardware, Virtual and Hybrid Audio Lineup Yet", slug: "calrec-redefines-broadcast-workflows-nab-2026" },
{ text: "Ease Live powers interactive Premier Padel experiences on Red Bull TV as global viewership surges 30%", slug: "ease-live-premier-padel-red-bull-tv" },
{ text: "Telestream Expands Its Cloud Services with the Introduction of UP", slug: "telestream-expands-cloud-services-up" },
{ text: "Operative Launches AOS Configuration for Digital-First Monetization", slug: "operative-launches-aos-configuration" },
{ text: "Matrox Video Enables the Next Era of Software-Defined Media Production at NAB 2026", slug: "matrox-video-software-defined-nab-2026" },
{ text: "GlobalM Introduces Distributed Video Gateway Architecture for Scalable Live IP Transport", slug: "globalm-distributed-video-gateway" },
{ text: "Digital Alert Systems Unveils Version 6 Software for DASDEC-III Platforms", slug: "digital-alert-systems-dasdec-iii-v6" },
];
export default function NewsTicker() {
const doubled = [...headlines, ...headlines];
return (
<div className="bg-[#cc0000] overflow-hidden border-b border-[#aa0000]">
<div className="flex items-stretch h-8">
{/* Label */}
<div className="flex-shrink-0 bg-[#080808] flex items-center px-4 z-10">
<span className="font-body text-xs font-bold text-white tracking-widest uppercase whitespace-nowrap">
THE BEAT
</span>
</div>
{/* Divider chevron */}
<div className="w-0 h-0 border-t-[32px] border-t-transparent border-l-[14px] border-l-[#080808] border-b-[0px] flex-shrink-0" />
{/* Scrolling track */}
<div className="flex-1 overflow-hidden flex items-center">
<div className="ticker-track">
{doubled?.map((item, i) => (
<span key={i} className="inline-flex items-center whitespace-nowrap text-white font-body font-normal text-sm mr-0">
<Link
href={`/articles/${item?.slug}`}
className="px-5 hover:text-white/80 transition-colors">
{item?.text}
</Link>
<span className="text-white/60 text-base">·</span>
</span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import React, { useState, useId } from "react";
const categories = [
"Broadcast",
"Post Production",
"Animation",
"AI & Automation",
"Live Production",
"NAB Show",
];
export default function NewsletterSignup() {
const [email, setEmail] = useState("");
const [selected, setSelected] = useState<string[]>(["Broadcast"]);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const formId = useId();
const toggleCategory = (cat: string) => {
setSelected((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError("Please enter a valid email address.");
return;
}
setError("");
setLoading(true);
try {
const response = await fetch("/api/newsletter/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, topics: selected }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to subscribe");
}
setSubmitted(true);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Something went wrong. Please try again.";
setError(message);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSubmit(e as unknown as React.FormEvent);
}
};
return (
<section
className="bg-[#0d1520] border-t border-b border-[#1e3a5f] py-8 md:py-10"
aria-labelledby={`${formId}-heading`}>
<div className="max-w-container mx-auto px-4">
<div className="max-w-2xl mx-auto text-center">
{/* Header */}
<div className="flex items-center justify-center gap-3 mb-3">
<div className="flex-1 h-px bg-[#1e3a5f] max-w-[80px]" />
<span className="section-label" id={`${formId}-heading`}>Newsletter</span>
<div className="flex-1 h-px bg-[#1e3a5f] max-w-[80px]" />
</div>
<p className="font-heading text-[#e0e0e0] text-xl md:text-2xl font-bold mb-1.5">
Stay Ahead of the Broadcast Industry
</p>
<p className="font-body text-[#777] text-sm mb-6">
Get the latest news, product launches, and industry insights delivered to your inbox.
</p>
{submitted ? (
<div
className="bg-[#1a2535] border border-[#3b82f6] rounded-sm px-6 py-8 text-center"
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">
<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" />
</svg>
</div>
<p className="font-heading text-[#e0e0e0] font-bold text-lg mb-1">You&apos;re subscribed!</p>
<p className="font-body text-[#777] text-sm">
Thanks for signing up. Check your inbox for a confirmation email.
</p>
</div>
) : (
<form onSubmit={handleSubmit} noValidate aria-label="Newsletter signup form">
{/* Category checkboxes */}
<fieldset className="mb-5">
<legend className="font-body text-xs font-bold text-[#888] uppercase tracking-wider mb-3">
Topics you care about
</legend>
<div className="flex flex-wrap justify-center gap-2">
{categories.map((cat) => {
const checked = selected.includes(cat);
return (
<label
key={cat}
className={`newsletter-chip ${checked ? "newsletter-chip-active" : ""}`}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleCategory(cat)}
className="sr-only"
aria-label={`Subscribe to ${cat} news`}
/>
<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]"}`}>
{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" />
</svg>
)}
</span>
{cat}
</label>
);
})}
</div>
</fieldset>
{/* Email input + submit */}
<div className="flex flex-col sm:flex-row gap-2 max-w-md mx-auto">
<div className="flex-1 relative">
<label htmlFor={`${formId}-email`} className="sr-only">Email address</label>
<input
id={`${formId}-email`}
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(""); }}
onKeyDown={handleKeyDown}
placeholder="Enter your email address"
autoComplete="email"
aria-required="true"
aria-describedby={error ? `${formId}-error` : undefined}
aria-invalid={!!error}
disabled={loading}
className={`search-input w-full py-2.5 px-4 text-sm ${error ? "border-red-500 focus:border-red-500" : ""} ${loading ? "opacity-60 cursor-not-allowed" : ""}`}
/>
</div>
<button
type="submit"
disabled={loading}
className={`btn-subscribe py-2.5 px-6 text-sm whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0d1520] ${loading ? "opacity-70 cursor-not-allowed" : ""}`}>
{loading ? "Subscribing..." : "Subscribe Free"}
</button>
</div>
{error && (
<p
id={`${formId}-error`}
className="font-body text-red-400 text-xs mt-2"
role="alert"
aria-live="assertive">
{error}
</p>
)}
<p className="font-body text-[#555] text-xs mt-3">
No spam. Unsubscribe anytime. By subscribing you agree to our{" "}
<a href="/privacy" className="text-[#3b82f6] hover:underline focus:outline-none focus-visible:underline">
Privacy Policy
</a>.
</p>
</form>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import React, { useState, useRef } from "react";
import AppImage from "@/components/ui/AppImage";
import { ChevronLeftIcon, ChevronRightIcon } from "@/components/ui/Icons";
import Link from "next/link";
const spotlights = [
{
name: "Vizrt",
title: "From Start-Up to Global Powerhouse in Video in Just Two Decades",
image: "https://www.broadcastbeat.com/wp-content/uploads/michael-320x220.jpg",
alt: "Vizrt company spotlight profile image",
tag: "Company",
href: "/news"
},
{
name: "Scott Freeman",
title: "The Sherlock Holmes of Video Post-Production",
image: "https://www.broadcastbeat.com/wp-content/uploads/scott-freeman.jpg",
alt: "Scott Freeman video post-production professional headshot",
tag: "Profile",
href: "/news"
},
{
name: "Al Roker",
title: "A Weatherman Who Wears Many Hats",
image: "https://www.broadcastbeat.com/wp-content/uploads/al-roker2-320x220.jpg",
alt: "Al Roker television weatherman and broadcaster portrait",
tag: "Profile",
href: "/news"
},
{
name: "Michael Kammes",
title: "Shaping the Best Workflow for Media Organizations",
image: "https://www.broadcastbeat.com/wp-content/uploads/kammes-2-320x220.jpg",
alt: "Michael Kammes media workflow specialist headshot",
tag: "Profile",
href: "/news"
},
{
name: "Gary Trenda",
title: "Top Frequency \'Traffic Cop\' for Wireless Microphones",
image: "https://www.broadcastbeat.com/wp-content/uploads/gary-320x220.jpg",
alt: "Gary Trenda wireless microphone frequency coordinator portrait",
tag: "Profile",
href: "/news"
},
{
name: "Jim Anderson & Ulrike Schwarz",
title: "Partners in Immersive Sound",
image: "https://www.broadcastbeat.com/wp-content/uploads/urike-320x220.jpg",
alt: "Jim Anderson and Ulrike Schwarz immersive sound designers",
tag: "Profile",
href: "/news"
},
{
name: "Sound Effects",
title: "Creating Cutting-Edge New Sound Effects — Film by Film",
image: "https://www.broadcastbeat.com/wp-content/uploads/mark-spot-320x215.jpg",
alt: "Sound effects creation for film and broadcast production",
tag: "Feature",
href: "/gear"
},
{
name: "Libby Casey",
title: "Pioneering Television Broadcasting at the Washington Post",
image: "https://www.broadcastbeat.com/wp-content/uploads/spotlight-320x215.jpg",
alt: "Libby Casey television broadcaster Washington Post studio",
tag: "Profile",
href: "/news"
},
{
name: "NBCU Academy",
title: "Preparing Students for Careers in News, Technology and Broadcast",
image: "https://www.broadcastbeat.com/wp-content/uploads/nbcu-spotlight-320x215.jpg",
alt: "NBCU Academy students learning broadcast journalism and technology",
tag: "Education",
href: "/about"
},
{
name: "BirdDog",
title: "Simplifying Global Television Production",
image: "https://www.broadcastbeat.com/wp-content/uploads/bird-sptfeat.jpg",
alt: "BirdDog broadcast production technology company spotlight",
tag: "Company",
href: "/news"
},
{
name: "Michael Cioni",
title: "Speeding Digital Cinema with Camera to Cloud",
image: "https://www.broadcastbeat.com/wp-content/uploads/michael-copy-320x220.jpg",
alt: "Michael Cioni digital cinema and camera to cloud technology expert",
tag: "Profile",
href: "/technology"
},
{
name: "Jeff Greenberg",
title: "Finding Workarounds for Gaps in the Video Production Workflow",
image: "https://www.broadcastbeat.com/wp-content/uploads/greenberg-320x215.jpg",
alt: "Jeff Greenberg video production workflow consultant headshot",
tag: "Profile",
href: "/technology"
}
];
const VISIBLE = 4;
export default function SpotlightCarousel() {
const [startIndex, setStartIndex] = useState(0);
const [animating, setAnimating] = useState(false);
const prevBtnRef = useRef<HTMLButtonElement>(null);
const nextBtnRef = useRef<HTMLButtonElement>(null);
const total = spotlights.length;
const visible = spotlights.slice(startIndex, startIndex + VISIBLE);
const visibleItems =
visible.length < VISIBLE
? [...visible, ...spotlights.slice(0, VISIBLE - visible.length)]
: visible;
const go = (dir: 1 | -1) => {
if (animating) return;
setAnimating(true);
setStartIndex((prev) => (prev + dir * VISIBLE + total) % total);
setTimeout(() => setAnimating(false), 350);
};
// Arrow key navigation on carousel container
const handleCarouselKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
prevBtnRef.current?.focus();
} else if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
nextBtnRef.current?.focus();
}
};
return (
<section
suppressHydrationWarning
className="bg-[#0d0d0d] py-6 md:py-8 border-t border-b border-[#222]"
aria-label="Spotlight carousel"
onKeyDown={handleCarouselKeyDown}>
<div suppressHydrationWarning className="max-w-container mx-auto px-4">
{/* Header */}
<div className="flex items-center justify-between mb-4 md:mb-5">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
<span className="section-label flex-shrink-0">Spotlight</span>
<span className="text-[#555] font-body text-xs md:text-sm truncate hidden sm:block">Industry Personalities &amp; Companies</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 ml-3" role="group" aria-label="Carousel controls">
<button
ref={prevBtnRef}
onClick={() => go(-1)}
aria-label="Previous spotlight (Left arrow key)"
className="carousel-btn w-7 h-7 border border-[#333] bg-[#1a1a1a] hover:border-[#3b82f6] hover:text-[#3b82f6] hover:bg-[#1e1e1e] flex items-center justify-center transition-colors text-[#888] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
<ChevronLeftIcon size={13} />
</button>
<button
ref={nextBtnRef}
onClick={() => go(1)}
aria-label="Next spotlight (Right arrow key)"
className="carousel-btn w-7 h-7 border border-[#333] bg-[#1a1a1a] hover:border-[#3b82f6] hover:text-[#3b82f6] hover:bg-[#1e1e1e] flex items-center justify-center transition-colors text-[#888] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
<ChevronRightIcon size={13} />
</button>
</div>
</div>
{/* Cards */}
<div
className={`grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4 ${
animating ? "opacity-60" : "opacity-100"
} transition-opacity duration-300`}
role="list"
aria-label={`Spotlight items ${startIndex + 1} to ${Math.min(startIndex + VISIBLE, total)} of ${total}`}>
{visibleItems.map((item, i) => (
<Link
key={`${startIndex}-${i}`}
href={item.href}
role="listitem"
className={`spotlight-card group block spotlight-slide-in focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${
i >= 3 ? "hidden lg:block" : i >= 2 ? "hidden md:block" : ""
}`}
style={{ animationDelay: `${i * 50}ms` }}
aria-label={`${item.name}: ${item.title}`}>
<div className="img-zoom relative overflow-hidden h-[130px] sm:h-[140px] md:h-[150px] lg:h-[155px]">
<AppImage
src={item.image}
alt={item.alt}
fill
className="object-cover w-full h-full"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
/>
<div className="absolute top-0 left-0">
<span className="bg-accent text-white font-body text-[9px] font-bold px-1.5 py-0.5 tracking-wider uppercase">
{item.tag}
</span>
</div>
</div>
<div className="p-2.5 md:p-3 border-t border-[#2a2a2a]">
<p className="font-body text-[11px] font-bold text-[#3b82f6] uppercase tracking-wide mb-1">
{item.name}
</p>
<h3 className="font-heading text-[#e0e0e0] text-[12px] md:text-[13px] font-bold leading-snug line-clamp-2 group-hover:text-[#3b82f6] transition-colors duration-200">
{item.title}
</h3>
</div>
</Link>
))}
</div>
{/* Dots */}
<div className="flex justify-center gap-1.5 mt-4 md:mt-5" role="tablist" aria-label="Spotlight pages">
{Array.from({ length: Math.ceil(total / VISIBLE) }).map((_, i) => (
<button
key={i}
onClick={() => setStartIndex(i * VISIBLE)}
role="tab"
aria-selected={Math.floor(startIndex / VISIBLE) === i}
aria-label={`Go to spotlight page ${i + 1}`}
className={`h-1.5 rounded-full transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] ${
Math.floor(startIndex / VISIBLE) === i
? "w-5 bg-accent" : "w-1.5 bg-[#333] hover:bg-[#555]"
}`}
/>
))}
</div>
</div>
</section>
);
}

198
src/app/home-page/page.tsx Normal file
View File

@@ -0,0 +1,198 @@
import React, { Suspense } from "react";
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import NewsTicker from "./components/NewsTicker";
import LeaderboardAd from "./components/LeaderboardAd";
import FeaturedBento from "./components/FeaturedBento";
import SpotlightCarousel from "./components/SpotlightCarousel";
import ArticleFeed from "./components/ArticleFeed";
import ScrollRevealSection from "@/components/ScrollRevealSection";
import NewsletterSignup from "./components/NewsletterSignup";
import AISuggestedArticles from "@/components/AISuggestedArticles";
export const metadata: Metadata = {
title: 'BroadcastBeat — Broadcast Engineering News & Insights',
description: 'The digital platform for broadcast engineering professionals. Breaking news, deep-dive features, and industry spotlights from NAB to IBC and beyond.',
alternates: {
canonical: '/home-page',
},
openGraph: {
title: 'BroadcastBeat — Broadcast News',
description: 'Breaking news and insights for broadcast engineering professionals.',
url: '/home-page',
type: 'website',
images: [
{
url: '/assets/images/og-image.png',
width: 1200,
height: 630,
alt: 'BroadcastBeat — Broadcast Engineering News & Insights',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'BroadcastBeat — Broadcast News',
description: 'Breaking news and insights for broadcast engineering professionals.',
images: ['/assets/images/og-image.png'],
},
};
/* ── Skeleton placeholders ─────────────────────────────── */
function TickerSkeleton() {
return (
<div className="w-full h-8 skeleton" style={{ borderRadius: 0 }} />
);
}
function LeaderboardSkeleton() {
return (
<div className="max-w-container mx-auto px-4 py-3">
<div className="skeleton h-[90px] w-full rounded-sm" />
</div>
);
}
function BentoSkeleton() {
return (
<section className="max-w-container mx-auto px-4 py-4 md:py-6">
<div className="flex items-center gap-3 mb-4">
<div className="skeleton h-4 w-20 rounded-sm" />
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-3 md:gap-4">
<div className="lg:col-span-7 skeleton rounded-sm h-[260px] sm:h-[320px] md:h-[380px]" />
<div className="lg:col-span-5 grid grid-cols-2 gap-3 md:gap-4">
{[...Array(4)]?.map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton h-[110px] sm:h-[130px]" />
<div className="p-2 space-y-1.5">
<div className="skeleton h-3 w-16 rounded-sm" />
<div className="skeleton h-4 w-full rounded-sm" />
<div className="skeleton h-4 w-3/4 rounded-sm" />
</div>
</div>
))}
</div>
</div>
</section>
);
}
function CarouselSkeleton() {
return (
<div className="py-6 px-4">
<div className="max-w-container mx-auto">
<div className="flex items-center gap-3 mb-4">
<div className="skeleton h-4 w-24 rounded-sm" />
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(4)]?.map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton h-[140px]" />
<div className="p-3 space-y-2">
<div className="skeleton h-3 w-16 rounded-sm" />
<div className="skeleton h-4 w-full rounded-sm" />
<div className="skeleton h-4 w-2/3 rounded-sm" />
</div>
</div>
))}
</div>
</div>
</div>
);
}
function FeedSkeleton() {
return (
<section className="max-w-container mx-auto px-4 py-4 md:py-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 lg:gap-8">
<div className="lg:col-span-8 space-y-1">
<div className="flex items-center gap-3 mb-4">
<div className="skeleton h-4 w-20 rounded-sm" />
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
{[...Array(5)]?.map((_, i) => (
<div key={i} className="flex gap-4 py-4 border-b border-[#222]">
<div className="skeleton flex-shrink-0 w-[140px] h-[95px] rounded-sm" />
<div className="flex-1 space-y-2">
<div className="skeleton h-3 w-24 rounded-sm" />
<div className="skeleton h-5 w-full rounded-sm" />
<div className="skeleton h-5 w-4/5 rounded-sm" />
<div className="skeleton h-3 w-32 rounded-sm" />
</div>
</div>
))}
</div>
<div className="lg:col-span-4 space-y-4">
<div className="skeleton h-[250px] rounded-sm" />
<div className="skeleton h-[200px] rounded-sm" />
</div>
</div>
</section>
);
}
export default function HomePage() {
return (
<div className="min-h-screen bg-background">
{/* Sticky top bar + nav */}
<Header />
{/* H1 Hero Heading */}
<div className="sr-only">
<h1>BroadcastBeat Broadcast Engineering News & Insights</h1>
</div>
{/* Scrolling news ticker */}
<div className="section-enter section-enter-1">
<Suspense fallback={<TickerSkeleton />}>
<NewsTicker />
</Suspense>
</div>
{/* Leaderboard ad */}
<div className="section-enter section-enter-2">
<Suspense fallback={<LeaderboardSkeleton />}>
<LeaderboardAd />
</Suspense>
</div>
{/* Featured articles bento */}
<ScrollRevealSection className="section-enter section-enter-3">
<Suspense fallback={<BentoSkeleton />}>
<FeaturedBento />
</Suspense>
</ScrollRevealSection>
{/* Spotlight carousel — dark navy band */}
<ScrollRevealSection className="section-enter section-enter-4" delay={1}>
<Suspense fallback={<CarouselSkeleton />}>
<SpotlightCarousel />
</Suspense>
</ScrollRevealSection>
{/* AI Suggested Articles */}
<ScrollRevealSection className="section-enter section-enter-5" delay={1}>
<AISuggestedArticles variant="compact" />
</ScrollRevealSection>
{/* Article feed + sidebar */}
<ScrollRevealSection className="section-enter section-enter-5" delay={2}>
<Suspense fallback={<FeedSkeleton />}>
<ArticleFeed />
</Suspense>
</ScrollRevealSection>
{/* Newsletter signup */}
<ScrollRevealSection className="section-enter section-enter-5">
<NewsletterSignup />
</ScrollRevealSection>
{/* Footer */}
<Footer />
</div>
);
}