initial commit: rocket.new export of broadcastbeat
This commit is contained in:
959
src/app/home-page/components/ArticleFeed.tsx
Normal file
959
src/app/home-page/components/ArticleFeed.tsx
Normal file
@@ -0,0 +1,959 @@
|
||||
"use client";
|
||||
import React, { useState, useRef, useId, useEffect, useCallback } from "react";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons";
|
||||
import Link from "next/link";
|
||||
|
||||
|
||||
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 staticArticles: ArticleItem[] = [
|
||||
{
|
||||
title: "Telestream Expands Its Cloud Services with the Introduction of UP",
|
||||
excerpt: "New cloud-native platform extends Telestream Cloud Services to support Global Ingest, automation, review, and real-time monitoring across hybrid and distributed production environments.",
|
||||
category: "BROADCAST",
|
||||
date: "March 11, 2026",
|
||||
author: "Staff Reporter",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "telestream-expands-cloud-services-up",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_11ff3d2ff-1773287949660.png",
|
||||
alt: "Cloud services infrastructure for broadcast production environments",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Operative Launches AOS Configuration for Digital-First Monetization",
|
||||
excerpt: "Enterprise-grade intelligent ad management built to scale digital-first revenue across every channel — from linear to streaming and everything in between.",
|
||||
category: "BROADCAST",
|
||||
date: "March 11, 2026",
|
||||
author: "James Whitfield",
|
||||
authorSlug: "james-whitfield",
|
||||
slug: "operative-launches-aos-configuration",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1805d1575-1773287951103.png",
|
||||
alt: "Digital monetization analytics dashboard for broadcast advertising",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Calrec Redefines Broadcast Workflows at NAB 2026 with its Most Powerful Audio Lineup Yet",
|
||||
excerpt: "The broadcast industry is going through a rapid evolution that's signalling a more collaborative and location-independent future for live production.",
|
||||
category: "BROADCAST",
|
||||
date: "March 10, 2026",
|
||||
author: "Sarah Chen",
|
||||
authorSlug: "sarah-chen",
|
||||
slug: "calrec-redefines-broadcast-workflows-nab-2026",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_10468e182-1773287953747.png",
|
||||
alt: "Calrec audio mixing console for broadcast production at NAB 2026",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Ease Live Powers Interactive Premier Padel Experiences on Red Bull TV as Viewership Surges 30%",
|
||||
excerpt: "Real-time fan engagement enhances viewing and accelerates growth of the world's fastest-growing racket sport across digital broadcast platforms.",
|
||||
category: "BROADCAST",
|
||||
date: "March 10, 2026",
|
||||
author: "Marcus Rivera",
|
||||
authorSlug: "marcus-rivera",
|
||||
slug: "ease-live-premier-padel-red-bull-tv",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1747232a3-1773287951222.png",
|
||||
alt: "Interactive sports broadcast experience on streaming platform",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "From Metadata to Meaning: Why Semantic Intelligence Is Media's Next Competitive Advantage",
|
||||
excerpt: "Strategic conversations about content performance and audience intelligence are shifting the way media organizations approach their metadata strategies.",
|
||||
category: "FEATURED",
|
||||
date: "March 9, 2026",
|
||||
author: "Elena Vasquez",
|
||||
authorSlug: "elena-vasquez",
|
||||
slug: "from-metadata-to-meaning-semantic-intelligence",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1c523ae5f-1773287954372.png",
|
||||
alt: "Media metadata intelligence and content analytics visualization",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Mediagenix Title Management Accelerates Content Monetization Through Advanced Semantic Intelligence",
|
||||
excerpt: "Advancing Semantic Intelligence capabilities to help studios and networks unlock greater value from their content libraries and distribution pipelines.",
|
||||
category: "POST PRODUCTION",
|
||||
date: "March 9, 2026",
|
||||
author: "David Park",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "mediagenix-title-management",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_19f6e3fe6-1769744394519.png",
|
||||
alt: "Content management system for media title management and monetization",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Emergent Launches Fusion: The 'Interactive Anything' Platform Transforming Data-Driven Storytelling",
|
||||
excerpt: "No-code application builder designed to transform complex live data into immersive interactive storytelling experiences for broadcasters and enterprises.",
|
||||
category: "BROADCAST",
|
||||
date: "March 8, 2026",
|
||||
author: "Staff Reporter",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "emergent-launches-fusion-platform",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_188669b2b-1773287951738.png",
|
||||
alt: "Interactive data storytelling platform for broadcast and enterprise use",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "KOKUSAI DENKI Electric America Welcomes Mondae Hott as Regional Sales Manager, Northeast",
|
||||
excerpt: "Industry veteran joins the team to drive growth across the northeastern United States broadcast market.",
|
||||
category: "BROADCAST",
|
||||
date: "March 8, 2026",
|
||||
author: "Lisa Thompson",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "kokusai-denki-mondae-hott",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1b072efa4-1772991129883.png",
|
||||
alt: "Professional broadcast industry executive portrait in office setting",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Matrox Video Enables the Next Era of Software-Defined Media Production at NAB 2026",
|
||||
excerpt: "New solutions demonstrated at NAB 2026 showcase how software-defined workflows are reshaping the economics and flexibility of professional media production.",
|
||||
category: "BROADCAST",
|
||||
date: "March 7, 2026",
|
||||
author: "Carlos Mendez",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "matrox-video-software-defined-nab-2026",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_17266f6cf-1773287953058.png",
|
||||
alt: "Matrox video production hardware and software at NAB 2026 exhibition",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "GlobalM Introduces Distributed Video Gateway Architecture, Advancing Scalable Live IP Transport",
|
||||
excerpt: "New distributed architecture addresses the growing demand for scalable, resilient live IP transport solutions in modern broadcast infrastructure.",
|
||||
category: "BROADCAST",
|
||||
date: "March 7, 2026",
|
||||
author: "Staff Reporter",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "globalm-distributed-video-gateway",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1876db6da-1773287949606.png",
|
||||
alt: "Network infrastructure for distributed video gateway IP transport system",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Video is King: 2026 Iconik Media Stats Report Finds Video Consumes 64% of Storage Needs",
|
||||
excerpt: "Annual media statistics report reveals video's growing dominance in enterprise storage requirements, with implications for media asset management strategies.",
|
||||
category: "FEATURED",
|
||||
date: "March 6, 2026",
|
||||
author: "Rachel Kim",
|
||||
authorSlug: "rachel-kim",
|
||||
slug: "video-is-king-2026-iconik-media-stats",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_140568f68-1773287953452.png",
|
||||
alt: "Video storage statistics and media asset management data visualization",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "QuickLink's Latest StudioEdge Models to Make North American Debut at NAB 2026",
|
||||
excerpt: "New StudioEdge models bring enhanced remote production capabilities and simplified IP workflows to North American broadcast professionals.",
|
||||
category: "BROADCAST",
|
||||
date: "March 6, 2026",
|
||||
author: "Tom Bradley",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "quicklink-studioedge-nab-2026",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_19c4ba922-1773287953682.png",
|
||||
alt: "QuickLink StudioEdge broadcast production equipment at NAB exhibition",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Cost Savings, Scalability, and Smarter Monetization: Viaccess-Orca Brings Efficiency to NAB 2026",
|
||||
excerpt: "Purpose-driven solutions designed to help broadcasters reduce operational costs while improving content monetization across all distribution channels.",
|
||||
category: "BROADCAST",
|
||||
date: "March 5, 2026",
|
||||
author: "Staff Reporter",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "viaccess-orca-nab-2026",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_12c71bf4d-1768883320639.png",
|
||||
alt: "Broadcast technology monetization solutions at NAB Show 2026",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Digital Alert Systems Unveils Version 6 Software for DASDEC-III Platforms",
|
||||
excerpt: "Major software update brings enhanced EAS capabilities, improved user interface, and expanded integration options for emergency alerting systems.",
|
||||
category: "BROADCAST",
|
||||
date: "March 5, 2026",
|
||||
author: "Nina Okafor",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "digital-alert-systems-dasdec-iii-v6",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_116c1855d-1773287954967.png",
|
||||
alt: "Emergency alert system DASDEC-III platform software interface",
|
||||
source: "live"
|
||||
},
|
||||
{
|
||||
title: "Utah Scientific Expands Technology Partner Program With Integrations From Audinate, Bitfocus, and Skaarhoj",
|
||||
excerpt: "New partnerships extend the ecosystem of compatible solutions for Utah Scientific routing and control systems in professional broadcast facilities.",
|
||||
category: "BROADCAST",
|
||||
date: "March 4, 2026",
|
||||
author: "Staff Reporter",
|
||||
authorSlug: "staff-reporter",
|
||||
slug: "utah-scientific-partner-program",
|
||||
image: "https://img.rocket.new/generatedImages/rocket_gen_img_1ea12f02f-1768375990494.png",
|
||||
alt: "Broadcast routing and control system technology partner integration",
|
||||
source: "live"
|
||||
}
|
||||
];
|
||||
|
||||
const sidebarAds = [
|
||||
{ src: "https://images.unsplash.com/photo-1611532736597-de2d4265fba3?w=300&h=600&fit=crop", alt: "Blackmagic Design advertisement 300x600", label: "Blackmagic", size: "300x600" },
|
||||
{ src: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=300&h=250&fit=crop", alt: "Zixi broadcast technology advertisement 300x250", label: "Zixi", size: "300x250" },
|
||||
{ src: "https://www.broadcastbeat.com/wp-content/uploads/728-x-90-pxEN.gif", alt: "LiveU live production solutions advertisement 300x250", label: "LiveU", size: "300x250" },
|
||||
{ src: "https://images.unsplash.com/photo-1563986768609-322da13575f3?w=300&h=250&fit=crop", alt: "TelyCam broadcast camera advertisement 300x250", label: "TelyCam", size: "300x250" },
|
||||
{ src: "https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?w=300&h=250&fit=crop", alt: "AJA Video Systems advertisement 300x250", label: "AJA", size: "300x250" },
|
||||
{ src: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=300&h=250&fit=crop", alt: "Studio Suite production management advertisement 300x250", label: "Studio Suite", size: "300x250" }
|
||||
];
|
||||
|
||||
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();
|
||||
|
||||
// Load preference from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("latest-feed-mode");
|
||||
if (saved === "infinite" || saved === "pagination") {
|
||||
setFeedMode(saved);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
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, ...staticArticles];
|
||||
|
||||
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; // 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 */}
|
||||
<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>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* Search bar row with Advanced Filters toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<label htmlFor={`${feedId}-search`} className="sr-only">Search articles</label>
|
||||
<SearchIcon
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#555] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id={`${feedId}-search`}
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||
placeholder="Search articles by title, topic, or author..."
|
||||
aria-label="Search articles"
|
||||
aria-controls={`${feedId}-results`}
|
||||
className="search-input search-input-enhanced w-full pl-9 pr-9 py-2 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
className="search-clear-btn right-2.5">
|
||||
<CloseIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 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" } : {};
|
||||
|
||||
return (
|
||||
<article key={i} 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>
|
||||
<span className="text-[#666] text-xs font-body hidden sm:inline">{article?.date}</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>
|
||||
);
|
||||
})}
|
||||
</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 */}
|
||||
<aside className="lg:col-span-4" aria-label="Advertisements">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<div className="grid grid-cols-2 gap-3 lg:hidden">
|
||||
{sidebarAds?.filter(ad => ad?.size !== "300x600")?.map((ad, i) => (
|
||||
<a key={i} href="#" aria-label={`${ad?.label} advertisement`} className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
||||
<div className="ad-placeholder overflow-hidden h-[120px] w-full relative">
|
||||
<AppImage
|
||||
src={ad?.src}
|
||||
alt={ad?.alt}
|
||||
fill
|
||||
className="w-full h-full object-cover"
|
||||
sizes="(max-width: 1024px) 50vw"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/40 text-white/60 font-body text-[9px] px-1 py-0.5">
|
||||
AD · {ad?.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex lg:flex-col lg:space-y-4">
|
||||
{sidebarAds?.map((ad, i) => (
|
||||
<a key={i} href="#" aria-label={`${ad?.label} advertisement`} className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
||||
<div
|
||||
className={`ad-placeholder overflow-hidden ${ad?.size === "300x600" ? "h-[600px]" : "h-[250px]"}`}
|
||||
style={{ width: "100%", maxWidth: 300 }}>
|
||||
<AppImage
|
||||
src={ad?.src}
|
||||
alt={ad?.alt}
|
||||
width={300}
|
||||
height={ad?.size === "300x600" ? 600 : 250}
|
||||
className="w-full h-full object-cover"
|
||||
sizes="300px"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/40 text-white/60 font-body text-[9px] px-1 py-0.5">
|
||||
AD · {ad?.label}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user