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

752 lines
36 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 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;
}
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();
// 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=200", { 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 === 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>
{/* Filters only — site-wide search lives in the top-right header */}
<div className="mb-4 space-y-3">
<div className="flex items-center gap-2">
{/* 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" } : {};
// 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">
<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>
<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>
{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 className="lg:col-span-4" aria-label="Advertisements">
<div className="lg:sticky lg:top-24">
<SidebarAdStack />
</div>
</aside>
</div>
</section>
);
}