initial commit: rocket.new export of broadcastbeat
This commit is contained in:
959
src/app/home-page/components/ArticleFeed.tsx
Normal file
959
src/app/home-page/components/ArticleFeed.tsx
Normal 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; // 0–199 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>
|
||||
);
|
||||
}
|
||||
270
src/app/home-page/components/FeaturedBento.tsx
Normal file
270
src/app/home-page/components/FeaturedBento.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/home-page/components/LeaderboardAd.tsx
Normal file
22
src/app/home-page/components/LeaderboardAd.tsx
Normal 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>);
|
||||
|
||||
}
|
||||
48
src/app/home-page/components/NewsTicker.tsx
Normal file
48
src/app/home-page/components/NewsTicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
src/app/home-page/components/NewsletterSignup.tsx
Normal file
179
src/app/home-page/components/NewsletterSignup.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
234
src/app/home-page/components/SpotlightCarousel.tsx
Normal file
234
src/app/home-page/components/SpotlightCarousel.tsx
Normal 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 & 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
198
src/app/home-page/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user