813 lines
42 KiB
TypeScript
813 lines
42 KiB
TypeScript
"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; // 0–199 simulated comment count
|
||
};
|
||
return commentScore(b) - commentScore(a);
|
||
}
|
||
return 0;
|
||
});
|
||
|
||
const totalPages = Math.max(1, Math.ceil(sortedFiltered.length / ARTICLES_PER_PAGE));
|
||
const displayedArticles = feedMode === "infinite"
|
||
? sortedFiltered.slice(0, infiniteCount)
|
||
: sortedFiltered.slice((page - 1) * ARTICLES_PER_PAGE, page * ARTICLES_PER_PAGE);
|
||
|
||
const hasMore = feedMode === "infinite" && infiniteCount < sortedFiltered.length;
|
||
|
||
const importedCount = allArticles.filter((a) => a.source === "imported").length;
|
||
|
||
// Reset infinite count when filters change
|
||
useEffect(() => {
|
||
setInfiniteCount(ARTICLES_PER_PAGE);
|
||
}, [searchQuery, activeCategory, sourceFilter, authorFilter, dateFrom, dateTo, sortOrder]);
|
||
|
||
// Arrow key navigation for filter chips
|
||
const handleFilterKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||
const items = filterRefs.current.filter(Boolean);
|
||
if (e.key === "ArrowRight") {
|
||
e.preventDefault();
|
||
items[(index + 1) % items.length]?.focus();
|
||
} else if (e.key === "ArrowLeft") {
|
||
e.preventDefault();
|
||
items[(index - 1 + items.length) % items.length]?.focus();
|
||
} else if (e.key === "Home") {
|
||
e.preventDefault();
|
||
items[0]?.focus();
|
||
} else if (e.key === "End") {
|
||
e.preventDefault();
|
||
items[items.length - 1]?.focus();
|
||
}
|
||
};
|
||
|
||
const clearSearch = () => {
|
||
setSearchQuery("");
|
||
searchRef.current?.focus();
|
||
};
|
||
|
||
// Infinite scroll: IntersectionObserver on sentinel
|
||
const loadMore = useCallback(() => {
|
||
if (isLoadingMore || !hasMore) return;
|
||
setIsLoadingMore(true);
|
||
setTimeout(() => {
|
||
setInfiniteCount((prev) => Math.min(prev + ARTICLES_PER_PAGE, sortedFiltered.length));
|
||
setIsLoadingMore(false);
|
||
}, 400);
|
||
}, [isLoadingMore, hasMore, sortedFiltered.length]);
|
||
|
||
useEffect(() => {
|
||
if (feedMode !== "infinite") return;
|
||
const sentinel = sentinelRef.current;
|
||
if (!sentinel) return;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries[0].isIntersecting) {
|
||
loadMore();
|
||
}
|
||
},
|
||
{ rootMargin: "200px" }
|
||
);
|
||
observer.observe(sentinel);
|
||
return () => observer.disconnect();
|
||
}, [feedMode, loadMore]);
|
||
|
||
return (
|
||
<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>
|
||
);
|
||
}
|