327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useMemo, useEffect } from "react";
|
||
import Link from "next/link";
|
||
import { useSearchParams } from "next/navigation";
|
||
import Header from "@/components/Header";
|
||
import Footer from "@/components/Footer";
|
||
import AppImage from "@/components/ui/AppImage";
|
||
import type { Article } from "@/lib/articles/types";
|
||
import AISuggestedArticles from "@/components/AISuggestedArticles";
|
||
|
||
const CATEGORIES = ["All", "Cloud", "AI", "IP Workflows", "Live Production", "Streaming", "Audio", "Cameras", "NAB 2026", "EAS", "Sports Broadcasting", "Ad Tech"];
|
||
|
||
// Map URL category slugs (?category=live-production) to a matching CATEGORIES label.
|
||
function categoryFromSlug(slug: string | null): string {
|
||
if (!slug) return "All";
|
||
const norm = slug.toLowerCase().replace(/[-_]+/g, " ").trim();
|
||
const hit = CATEGORIES.find((c) => c.toLowerCase() === norm);
|
||
return hit || "All";
|
||
}
|
||
|
||
const SORT_OPTIONS = [
|
||
{ value: "newest", label: "Newest First" },
|
||
{ value: "oldest", label: "Oldest First" },
|
||
{ value: "az", label: "A → Z" },
|
||
{ value: "za", label: "Z → A" },
|
||
];
|
||
|
||
function parseArticleDate(dateStr: string): Date {
|
||
return new Date(dateStr);
|
||
}
|
||
|
||
export default function NewsPage({ articles }: { articles: Article[] }) {
|
||
const allArticles = Array.isArray(articles) ? articles : [];
|
||
const searchParams = useSearchParams();
|
||
|
||
const [search, setSearch] = useState("");
|
||
const [activeCategory, setActiveCategory] = useState("All");
|
||
const [dateFrom, setDateFrom] = useState("");
|
||
const [dateTo, setDateTo] = useState("");
|
||
const [sortBy, setSortBy] = useState("newest");
|
||
|
||
// Hydrate filter state from URL (?category=live-production&search=foo)
|
||
useEffect(() => {
|
||
const cat = categoryFromSlug(searchParams?.get("category") ?? null);
|
||
if (cat !== "All") setActiveCategory(cat);
|
||
const q = searchParams?.get("search") ?? searchParams?.get("q");
|
||
if (q) setSearch(q);
|
||
}, [searchParams]);
|
||
|
||
const filtered = useMemo(() => {
|
||
let result = [...allArticles];
|
||
|
||
// Search filter
|
||
if (search.trim()) {
|
||
const q = search.toLowerCase();
|
||
result = result.filter((a) => {
|
||
const title = (a?.title ?? "").toLowerCase();
|
||
const excerpt = (a?.excerpt ?? "").toLowerCase();
|
||
const author = (a?.author ?? "").toLowerCase();
|
||
const tags = Array.isArray(a?.tags) ? a.tags : [];
|
||
return (
|
||
title.includes(q) ||
|
||
excerpt.includes(q) ||
|
||
author.includes(q) ||
|
||
tags.some((t) => (t ?? "").toLowerCase().includes(q))
|
||
);
|
||
});
|
||
}
|
||
|
||
// Category filter
|
||
if (activeCategory !== "All") {
|
||
const cat = activeCategory.toLowerCase();
|
||
result = result.filter((a) => {
|
||
const tags = Array.isArray(a?.tags) ? a.tags : [];
|
||
const category = (a?.category ?? "").toLowerCase();
|
||
return (
|
||
tags.some((t) => (t ?? "").toLowerCase() === cat) ||
|
||
category.includes(cat)
|
||
);
|
||
});
|
||
}
|
||
|
||
// Date range filter
|
||
if (dateFrom) {
|
||
const from = new Date(dateFrom);
|
||
result = result.filter((a) => parseArticleDate(a?.date ?? "") >= from);
|
||
}
|
||
if (dateTo) {
|
||
const to = new Date(dateTo);
|
||
to.setHours(23, 59, 59, 999);
|
||
result = result.filter((a) => parseArticleDate(a?.date ?? "") <= to);
|
||
}
|
||
|
||
// Sorting
|
||
result.sort((a, b) => {
|
||
const ta = (a?.title ?? "");
|
||
const tb = (b?.title ?? "");
|
||
if (sortBy === "newest") return parseArticleDate(b?.date ?? "").getTime() - parseArticleDate(a?.date ?? "").getTime();
|
||
if (sortBy === "oldest") return parseArticleDate(a?.date ?? "").getTime() - parseArticleDate(b?.date ?? "").getTime();
|
||
if (sortBy === "az") return ta.localeCompare(tb);
|
||
if (sortBy === "za") return tb.localeCompare(ta);
|
||
return 0;
|
||
});
|
||
|
||
return result;
|
||
}, [allArticles, search, activeCategory, dateFrom, dateTo, sortBy]);
|
||
|
||
const hasActiveFilters = search || activeCategory !== "All" || dateFrom || dateTo || sortBy !== "newest";
|
||
|
||
function clearFilters() {
|
||
setSearch("");
|
||
setActiveCategory("All");
|
||
setDateFrom("");
|
||
setDateTo("");
|
||
setSortBy("newest");
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<Header />
|
||
{/* DO NOT OVERRIDE — News page hero header */}
|
||
<div className="bg-[#111] border-b border-[#222] py-8">
|
||
<div className="max-w-container mx-auto px-4">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<span className="section-label">News</span>
|
||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
||
</div>
|
||
<h1 className="font-heading text-white text-3xl font-bold">Latest Broadcast News</h1>
|
||
<p className="text-[#777] font-body text-sm mt-2">Breaking news and industry updates from the world of broadcast engineering</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search & Filter Bar */}
|
||
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e] py-4">
|
||
<div className="max-w-container mx-auto px-4 space-y-3">
|
||
{/* Row 1: Search + Sort */}
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
{/* Search */}
|
||
<div className="relative flex-1">
|
||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||
</svg>
|
||
<input
|
||
type="text"
|
||
placeholder="Search articles, topics, authors…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] text-[#e0e0e0] placeholder-[#555] text-sm font-body pl-9 pr-4 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors"
|
||
/>
|
||
{search && (
|
||
<button
|
||
onClick={() => setSearch("")}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#555] hover:text-[#e0e0e0] transition-colors"
|
||
aria-label="Clear search"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Date Range */}
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="date"
|
||
value={dateFrom}
|
||
onChange={(e) => setDateFrom(e.target.value)}
|
||
className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors [color-scheme:dark]"
|
||
title="From date"
|
||
/>
|
||
<span className="text-[#444] text-xs">–</span>
|
||
<input
|
||
type="date"
|
||
value={dateTo}
|
||
onChange={(e) => setDateTo(e.target.value)}
|
||
className="bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors [color-scheme:dark]"
|
||
title="To date"
|
||
/>
|
||
</div>
|
||
|
||
{/* Sort */}
|
||
<div className="relative">
|
||
<select
|
||
value={sortBy}
|
||
onChange={(e) => setSortBy(e.target.value)}
|
||
className="appearance-none bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] text-xs font-body pl-3 pr-8 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] transition-colors cursor-pointer"
|
||
>
|
||
{SORT_OPTIONS.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||
))}
|
||
</select>
|
||
<svg className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-[#555] pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: Category 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">
|
||
{CATEGORIES.map((cat) => (
|
||
<button
|
||
key={cat}
|
||
onClick={() => setActiveCategory(cat)}
|
||
className={`px-2.5 py-1 text-[11px] font-body rounded-sm border transition-colors ${
|
||
activeCategory === cat
|
||
? "bg-[#3b82f6] border-[#3b82f6] text-white"
|
||
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]"
|
||
}`}
|
||
>
|
||
{cat}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{hasActiveFilters && (
|
||
<button
|
||
onClick={clearFilters}
|
||
className="ml-auto text-[11px] font-body text-[#555] hover:text-[#e0e0e0] underline transition-colors shrink-0"
|
||
>
|
||
Clear all
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<main className="max-w-container mx-auto px-4 py-8">
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||
{/* Article list */}
|
||
<div className="lg:col-span-8">
|
||
{/* Results count */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<p className="text-[#555] font-body text-xs">
|
||
{filtered.length === allArticles.length
|
||
? `${allArticles.length} articles`
|
||
: `${filtered.length} of ${allArticles.length} articles`}
|
||
</p>
|
||
</div>
|
||
|
||
{filtered.length === 0 ? (
|
||
<div className="py-16 text-center">
|
||
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<p className="text-[#555] font-body text-sm">No articles match your filters.</p>
|
||
<button onClick={clearFilters} className="mt-3 text-[#3b82f6] text-xs font-body hover:underline">Clear filters</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-0">
|
||
{filtered.map((article) => {
|
||
if (!article?.slug) return null;
|
||
return (
|
||
<Link
|
||
key={article.slug}
|
||
href={`/news/${article.slug}`}
|
||
className="flex gap-4 py-5 border-b border-[#222] group hover:bg-[#111] transition-colors px-2 -mx-2">
|
||
<div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm">
|
||
<AppImage
|
||
src={article.image || "/assets/images/article-placeholder.jpg"}
|
||
alt={article.alt || article.title || "BroadcastBeat article"}
|
||
fill
|
||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||
sizes="160px"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1.5">
|
||
<span className="text-[#3b82f6] font-body text-[10px] font-bold uppercase tracking-wider">{article.category || "NEWS"}</span>
|
||
</div>
|
||
<h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-2">
|
||
{article.title || "Untitled"}
|
||
</h2>
|
||
<p className="text-[#777] font-body text-sm leading-relaxed line-clamp-2">{article.excerpt || ""}</p>
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<span className="text-[#555] font-body text-xs">By {article.author || "Staff Reporter"}</span>
|
||
<span className="text-[#444] text-[10px]">·</span>
|
||
<span className="text-[#555] font-body text-xs">{article.readTime || ""}</span>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<aside className="lg:col-span-4 space-y-6">
|
||
{/* AI Suggested Articles */}
|
||
<AISuggestedArticles variant="full" />
|
||
|
||
<div className="bg-[#111] border border-[#222] p-4">
|
||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-3 pb-2 border-b border-[#222]">
|
||
Popular Tags
|
||
</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{["IP Workflows", "Cloud", "AI", "Live Production", "Streaming", "NAB 2026", "Audio", "Cameras"].map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={() => setActiveCategory(tag)}
|
||
className={`px-2 py-1 border text-xs font-body rounded-sm transition-colors ${
|
||
activeCategory === tag
|
||
? "bg-[#3b82f6] border-[#3b82f6] text-white"
|
||
: "bg-[#1a1a1a] border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6]"
|
||
}`}
|
||
>
|
||
{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="bg-[#0d1520] border border-[#1e3a5f] p-4">
|
||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-2">Newsletter</h3>
|
||
<p className="text-[#777] text-xs font-body mb-3">Get broadcast news delivered to your inbox every week.</p>
|
||
<Link href="/home-page#newsletter" className="btn-subscribe text-xs py-2 px-4 inline-block">Subscribe Free</Link>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</main>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|