Files
avbeat-com/src/app/home-page/components/ArticleFeed.tsx
Local Administrator 8042024c4a feat(brand): AV BEAT 2026 rebrand — blue/navy/white identity
- New AvBeatLogo inline-SVG component (rounded-square emblem with
  forward-leaning AV monogram + horizontal beam detail) at
  src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon).
- Header.tsx now uses the responsive AV BEAT logo lockup in the
  top-left, links to /, full lockup on desktop (emblem+wordmark+tagline),
  emblem+wordmark on tablet, emblem-only on mobile.
- Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip);
  the news ticker / glow chrome does not match the new aesthetic and the
  forum row was the explicit removal target.
- Tailwind theme + globals.css now expose the AV BEAT 2026 palette as
  semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8),
  --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border,
  --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the
  new tokens so any inline-styled component re-themes for free.
- Site-wide hex sweep migrates 2,769 hardcoded color literals across 144
  files from the old dark-broadcast palette to the new tokens (orange
  -> blue, dark-brown -> white surface / navy text, cream -> navy).
- Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text'
  so the dark glow chrome no longer leaks through the new light theme.
2026-06-03 12:07:18 +00:00

822 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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;
}
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();
// 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<Ad[]>(() => 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; // 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 — fixed 300px sidebar so right edge of 300x250 ads
lines up with the right edge of the Featured-story card above. */}
<div className="grid grid-cols-1 lg:grid-cols-[1fr,300px] gap-6 lg:gap-8">
{/* Main Feed */}
<div className="min-w-0">
{/* Section header */}
<div className="flex items-center gap-3 mb-4 md:mb-5 flex-wrap">
<span className="section-label">Industry News</span>
<div className="flex-1 h-px bg-[#DCE6F2]" />
{/* 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-[#DCE6F2] 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-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8]/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-[#DCE6F2] 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-[#1D4ED8] ${
feedMode === "pagination" ?"bg-[#1D4ED8]/20 text-[#1D4ED8] border border-[#1D4ED8]/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-[#1D4ED8] ${
feedMode === "infinite" ?"bg-[#1D4ED8]/20 text-[#1D4ED8] border border-[#1D4ED8]/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>
{/* Filters — topic chips + date range visible by default, advanced (author + source) behind toggle */}
<div className="mb-4 space-y-3">
{/* Row: date range + advanced toggle */}
<div className="flex items-center gap-2 flex-wrap">
<input
type="date"
value={dateFrom}
onChange={(e) => { 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"
/>
<span className="text-[#444] text-xs"></span>
<input
type="date"
value={dateTo}
onChange={(e) => { 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) && (
<button
type="button"
onClick={() => { setDateFrom(""); setDateTo(""); setPage(1); }}
className="text-[#666] hover:text-[#aaa] text-xs font-body underline"
aria-label="Clear date range">
clear dates
</button>
)}
<div className="flex-1" />
<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-[#1D4ED8] whitespace-nowrap ${
showAdvanced || hasAdvancedActive
? "bg-[#1D4ED8]/15 text-[#1D4ED8] border-[#1D4ED8]/40"
: "bg-[#111] text-[#666] border-[#DCE6F2] 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>
More
{hasAdvancedActive && (
<span className="w-1.5 h-1.5 rounded-full bg-[#1D4ED8] inline-block ml-0.5" aria-label="Active filters" />
)}
</button>
</div>
{/* Row: topic chips */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[#555] text-[10px] font-body uppercase tracking-wider shrink-0">Topics:</span>
<div className="flex gap-1.5 flex-wrap">
{ALL_CATEGORIES.map((cat) => {
const isActive = activeCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => { setActiveCategory(cat); setPage(1); }}
aria-pressed={isActive}
className={`px-3 py-1.5 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-[#1D4ED8] ${
isActive
? "bg-[#1D4ED8]/20 text-[#1D4ED8] border border-[#1D4ED8]/50"
: "bg-[#111] text-[#888] border border-[#DCE6F2] hover:text-[#ccc] hover:border-[#3a3a3a]"
}`}>
{cat}
</button>
);
})}
</div>
</div>
{/* Advanced filters panel */}
{showAdvanced && (
<div
id={`${feedId}-advanced`}
className="bg-[#F8FAFC] border border-[#DCE6F2] 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 (category + date moved out to the always-visible row) */}
<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-[#DCE6F2] text-[#ccc] font-body text-sm rounded-sm px-3 py-1.5 pr-8 appearance-none focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8]/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>
</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-[#DCE6F2] 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-[#1D4ED8] ${
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-[#1D4ED8]/20 text-[#1D4ED8] border border-[#1D4ED8]/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-[#1D4ED8] hover:underline focus:outline-none focus-visible:underline">
Clear advanced filters
</button>
</div>
)}
</div>
)}
{/* 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) && (
<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-[#1D4ED8] 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-[#1D4ED8] 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 && 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 (
<Fragment key={`feed-${i}`}>
<article className={`article-card flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2 ${promotedClass}`}>
<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-[#1D4ED8]"
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">
{promoted === 'premium' && (
<span className="font-body text-[9px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/40 px-1.5 py-0.5 rounded uppercase tracking-widest">
Featured
</span>
)}
{promoted === 'client' && (
<span className="font-body text-[9px] font-bold text-[#7aa7d4] bg-[#1D4ED8]/15 border border-[#1D4ED8]/40 px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Advertiser coverage: ${article.promoted_advertiser}` : 'Advertiser coverage'}>
Advertiser
</span>
)}
{article?.category && article.category.toLowerCase() !== "legacy" && (
<span className="font-body text-[10px] font-bold text-[#1D4ED8] uppercase tracking-wide">
{article.category}
</span>
)}
</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-[#1D4ED8] transition-colors duration-200 line-clamp-2">
<Link
href={articleHref}
{...linkProps}
className="focus:outline-none focus-visible:text-[#1D4ED8] 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">
{cleanExcerpt(article?.excerpt)}
</p>
<div className="mb-2">
<StarRating seed={article?.slug || article?.title || ""} publishedAt={article?.date} size="sm" />
</div>
<div className="flex items-center justify-between gap-2">
<div className="hidden sm:flex items-center gap-2 text-xs text-[#666] min-w-0">
<PersonIcon size={11} className="text-[#666] flex-shrink-0" />
<span className="font-body truncate">
By {article?.author}
</span>
{article?.date && (
<>
<span aria-hidden="true" className="text-[#333]">·</span>
<time className="font-body whitespace-nowrap" dateTime={article.date}>
{article.date}
</time>
</>
)}
</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}`}>
Read More...
</Link>
</div>
{/* Inline social share row */}
{(() => {
const shareUrl = `https://avbeat.com${articleHref.startsWith('/') ? articleHref : `/articles/${article?.slug}`}`;
const shareTitle = article?.title || '';
return (
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-[#FFFFFF]">
<span className="text-[#444] text-[9px] font-body uppercase tracking-wider mr-1">Share:</span>
<a href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`}
target="_blank" rel="noopener noreferrer" aria-label="Share on LinkedIn" title="LinkedIn"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#0a66c2] hover:bg-[#FFFFFF] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 0h-14C2.24 0 0 2.24 0 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5V5c0-2.76-2.24-5-5-5zM8 19H5V8h3v11zM6.5 6.73a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zM20 19h-3v-5.6c0-3.37-4-3.12-4 0V19h-3V8h3v1.77c1.4-2.59 7-2.78 7 2.48V19z"/></svg>
</a>
<a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(shareTitle)}&via=BroadcastBeat`}
target="_blank" rel="noopener noreferrer" aria-label="Share on X" title="X (Twitter)"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-white hover:bg-[#FFFFFF] transition-colors">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`}
target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook" title="Facebook"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#1877f2] hover:bg-[#FFFFFF] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.073c0-6.627-5.373-12-12-12S0 5.446 0 12.073C0 18.062 4.388 23.027 10.125 23.927v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</a>
<a href={`https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(shareTitle)}`}
target="_blank" rel="noopener noreferrer" aria-label="Share on Reddit" title="Reddit"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#ff4500] hover:bg-[#FFFFFF] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249z"/></svg>
</a>
<a href={`https://api.whatsapp.com/send?text=${encodeURIComponent(shareTitle + ' ' + shareUrl)}`}
target="_blank" rel="noopener noreferrer" aria-label="Share on WhatsApp" title="WhatsApp"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#25d366] hover:bg-[#FFFFFF] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.945C.16 5.335 5.495 0 12.05 0a11.817 11.817 0 0 1 8.413 3.488 11.824 11.824 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981z"/></svg>
</a>
<a href={`mailto:?subject=${encodeURIComponent(shareTitle)}&body=${encodeURIComponent(shareUrl)}`}
aria-label="Share via email" title="Email"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#666] hover:text-[#1D4ED8] hover:bg-[#FFFFFF] transition-colors">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</a>
</div>
);
})()}
</div>
</article>
{inFeedAd && (
<div
className="md:hidden py-5 flex flex-col items-center bg-[#0a0a0a]"
aria-label="Advertisement"
>
<span className="text-[9px] font-mono uppercase tracking-wider text-[#555] mb-2">
Sponsored
</span>
<AdImage ad={inFeedAd} />
</div>
)}
</Fragment>
);
})}
</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-[#1D4ED8] animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-[#1D4ED8] animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-[#1D4ED8] 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-[#1D4ED8]"
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-[#1D4ED8] ${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-[#1D4ED8]"
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 — 300x250 banners stacked. The 300x600 is rendered up in
FeaturedBento alongside the 3-up rail, so it's skipped here. */}
<aside aria-label="Advertisements">
<div className="lg:sticky lg:top-24">
<SidebarAdStack skipTop600 />
</div>
</aside>
</div>
</section>
);
}