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>
);
}