"use client"; import React, { Fragment, useState, useRef, useId, useEffect, useCallback, useMemo } from "react"; import AppImage from "@/components/ui/AppImage"; import SidebarAdStack from "@/components/SidebarAdStack"; import AdImage from "@/components/AdImage"; import { rotateAll, type Ad } from "@/lib/ads"; import { cleanExcerpt } from "@/lib/articles/excerpt"; import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons"; import StarRating from "@/components/StarRating"; import Link from "next/link"; const MOBILE_AD_EVERY_N = 4; 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; promoted_kind?: "premium" | "client" | null; promoted_advertiser?: string | null; promoted_until?: string | null; } // Match the topic list on /news for a consistent filter experience sitewide. const ALL_CATEGORIES = [ "All", "Cloud", "AI", "Live Production", "Streaming", "Audio", "Cameras", ]; 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(); 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; } export default function ArticleFeed() { const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(""); const [activeCategory, setActiveCategory] = useState("All"); const [sourceFilter, setSourceFilter] = useState("All"); const [wpPosts, setWpPosts] = useState([]); const [loadingWp, setLoadingWp] = useState(true); const [feedMode, setFeedMode] = useState("pagination"); const [infiniteCount, setInfiniteCount] = useState(ARTICLES_PER_PAGE); const [isLoadingMore, setIsLoadingMore] = useState(false); const [sortOrder, setSortOrder] = useState("recent"); // Advanced filters const [showAdvanced, setShowAdvanced] = useState(false); const [authorFilter, setAuthorFilter] = useState("All"); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); const searchRef = useRef(null); const filterRefs = useRef<(HTMLButtonElement | null)[]>([]); const sentinelRef = useRef(null); const feedId = useId(); // Shuffled 300x250 pool for mobile in-feed inserts (md:hidden). // Tablet/desktop already get the SidebarAdStack to the right of the feed. const mobileInfeedAds = useMemo(() => rotateAll("300x250"), []); // Load preference from localStorage useEffect(() => { try { const saved = localStorage.getItem("latest-feed-mode"); if (saved === "infinite" || saved === "pagination") { setFeedMode(saved); } } catch { // ignore } }, []); // Fetch imported WordPress posts for the feed useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch("/api/public/posts?limit=5000", { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); const items = Array.isArray(json?.items) ? (json.items as ArticleItem[]) : []; if (!cancelled) setWpPosts(items); } catch { if (!cancelled) setWpPosts([]); } finally { if (!cancelled) setLoadingWp(false); } })(); return () => { cancelled = true; }; }, []); 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]; 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 || "").toLowerCase().includes(activeCategory.toLowerCase()); 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. For every order we honor // the promoted-tier hierarchy first (premium > advertiser > general) so // paid placements and advertiser news always lead. Within each tier the // user's chosen sort applies. function promotedTier(a: ArticleItem): number { if (a.promoted_kind === "premium") return 0; if (a.promoted_kind === "client") return 1; return 2; } const sortedFiltered = [...filtered].sort((a, b) => { const ta = promotedTier(a); const tb = promotedTier(b); if (ta !== tb) return ta - tb; 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 (
{/* Main layout — fixed 300px sidebar so right edge of 300x250 ads lines up with the right edge of the Featured-story card above. */}
{/* Main Feed */}
{/* Section header */}
Industry News
{/* Sort dropdown */}
Sort:
{/* Feed mode toggle */}
View:
{sortedFiltered.length?.toLocaleString()} article{sortedFiltered.length !== 1 ? "s" : ""}
{/* Filters — topic chips + date range visible by default, advanced (author + source) behind toggle */}
{/* Row: date range + advanced toggle */}
{ setDateFrom(e.target.value); setPage(1); }} max={dateTo || undefined} className="bg-[#FFFFFF] border border-[#DCE6F2] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#1D4ED8] transition-colors [color-scheme:dark]" title="From date" aria-label="Filter articles from date" /> { setDateTo(e.target.value); setPage(1); }} min={dateFrom || undefined} className="bg-[#FFFFFF] border border-[#DCE6F2] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#1D4ED8] transition-colors [color-scheme:dark]" title="To date" aria-label="Filter articles to date" /> {(dateFrom || dateTo) && ( )}
{/* Row: topic chips */}
Topics:
{ALL_CATEGORIES.map((cat) => { const isActive = activeCategory === cat; return ( ); })}
{/* Advanced filters panel */} {showAdvanced && (
{/* Author filter (category + date moved out to the always-visible row) */}
{/* Source filter inside advanced panel */}
Source
{SOURCE_FILTERS.map((sf) => { const isActive = sourceFilter === sf; return ( ); })}
{loadingWp && ( Loading imported posts… )}
{/* Clear advanced filters */} {hasAdvancedActive && (
)}
)} {/* Legacy chip row removed — replaced by the always-visible "Topics:" chip row higher up. Single source of category selection now. */} {/* Results count */} {(searchQuery || activeCategory !== "All" || sourceFilter !== "All" || authorFilter !== "All" || dateFrom || dateTo) && (

{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 && ( )}

)}
{/* Article list */} {displayedArticles.length === 0 ? (

No articles found

Try adjusting your search or selecting a different category.

) : (
{displayedArticles?.map((article, i) => { const isImported = article.source === "imported"; const articleHref = isImported && article.externalUrl ? article.externalUrl : `/articles/${article?.slug}`; const linkProps = isImported && article.externalUrl ? { target: "_blank", rel: "noopener noreferrer" } : {}; // Mobile in-feed ad after every Nth article (skip if last item). const isAdSlot = (i + 1) % MOBILE_AD_EVERY_N === 0 && i + 1 < displayedArticles.length && mobileInfeedAds.length > 0; const inFeedAd = isAdSlot ? mobileInfeedAds[Math.floor((i + 1) / MOBILE_AD_EVERY_N - 1) % mobileInfeedAds.length] : null; const promoted = article?.promoted_kind; const promotedClass = promoted === 'premium' ? 'border-l-4 border-amber-400 bg-gradient-to-r from-amber-500/10 to-transparent pl-3 -ml-2 mr-0' : promoted === 'client' ? 'border-l-4 border-[#1D4ED8] bg-gradient-to-r from-[#1D4ED8]/8 to-transparent pl-3 -ml-2 mr-0' : ''; return (
{promoted === 'premium' && ( ★ Featured )} {promoted === 'client' && ( ◆ Advertiser )} {article?.category && article.category.toLowerCase() !== "legacy" && ( {article.category} )}

{article?.title}

{cleanExcerpt(article?.excerpt)}

By {article?.author} {article?.date && ( <> )}
Read More...
{/* Inline social share row */} {(() => { const shareUrl = `https://avbeat.com${articleHref.startsWith('/') ? articleHref : `/articles/${article?.slug}`}`; const shareTitle = article?.title || ''; return (
Share: e.stopPropagation()} className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#0a66c2] hover:bg-[#FFFFFF] transition-colors"> e.stopPropagation()} className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-white hover:bg-[#FFFFFF] transition-colors"> e.stopPropagation()} className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#1877f2] hover:bg-[#FFFFFF] transition-colors"> e.stopPropagation()} className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#ff4500] hover:bg-[#FFFFFF] transition-colors"> e.stopPropagation()} className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#25d366] hover:bg-[#FFFFFF] transition-colors"> e.stopPropagation()} className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#1D4ED8] hover:bg-[#FFFFFF] transition-colors">
); })()}
{inFeedAd && (
Sponsored
)}
); })}
)} {/* Infinite scroll sentinel + loading indicator */} {feedMode === "infinite" && (
{isLoadingMore && (
Loading more articles…
)} {!hasMore && displayedArticles.length > 0 && !isLoadingMore && (
All {sortedFiltered.length} articles loaded
)}
)} {/* Pagination */} {feedMode === "pagination" && sortedFiltered.length > ARTICLES_PER_PAGE && (

Page {page} of{" "} {totalPages?.toLocaleString()} ({sortedFiltered.length} articles)

{(() => { 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 === "..." ? ( ) : ( ) ); })()}
)}
{/* Sidebar — 300x250 banners stacked. The 300x600 is rendered up in FeaturedBento alongside the 3-up rail, so it's skipped here. */}
); }