Files
avbeat-com/src/app/home-page/components/ArticleFeed.tsx

813 lines
42 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 { 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;
}
// Match the topic list on /news for a consistent filter experience sitewide.
const ALL_CATEGORIES = [
"All",
"Cloud",
"AI",
"IP Workflows",
"Live Production",
"Streaming",
"Audio",
"Cameras",
"NAB 2026",
"EAS",
"Sports Broadcasting",
"Ad Tech",
];
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
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 — 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-[#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>
{/* 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-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#3b82f6] 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-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2 rounded-sm focus:outline-none focus:border-[#3b82f6] 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-[#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>
More
{hasAdvancedActive && (
<span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6] 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-[#3b82f6] ${
isActive
? "bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/50"
: "bg-[#111] text-[#888] border border-[#2a2a2a] hover:text-[#ccc] hover:border-[#3a3a3a]"
}`}>
{cat}
</button>
);
})}
</div>
</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 (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-[#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>
</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 && 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;
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">
<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">
{article?.category && article.category.toLowerCase() !== "legacy" && (
<span className="font-body text-[10px] font-bold text-[#3b82f6] 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-[#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="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://broadcastbeat.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-[#1a1a1a]">
<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-[#1a1a1a] 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-[#1a1a1a] 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-[#1a1a1a] 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-[#1a1a1a] 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-[#1a1a1a] 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-[#3b82f6] hover:bg-[#1a1a1a] 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-[#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 — Blackmagic 300x600 fixed at top + every 300x250 banner stacked */}
<aside aria-label="Advertisements">
<div className="lg:sticky lg:top-24">
<SidebarAdStack />
</div>
</aside>
</div>
</section>
);
}