P0-3/4/5: route error boundary, null guards, slug fallback, search
P0-3 (category page crash):
- Add src/app/error.tsx and src/app/global-error.tsx so any future render
error shows a real ref id instead of the bare client-exception toast.
- NewsPageClient: null-guard every field accessed during search/category/
sort filtering and article render (title, excerpt, author, tags,
category, date, image, alt). The user-visible link
/news?category=live-production now also hydrates filter state from
?category= and ?search= search-params, so the dropdown actually filters.
P0-4 (/articles/[slug] 404s):
- /articles/[slug] and /news/[slug]: on slug miss, redirect("/news")
instead of notFound(). Dead links from the hardcoded NewsTicker /
FeaturedBento / ArticleFeed arrays now land on the news index instead
of dead-ending on 404.
- legacy-source: add searchLegacyArticles() (title/excerpt/author ilike).
P0-5 (header search):
- Header: wire Enter on the desktop and mobile search inputs and make
both search icons clickable buttons. Submits to /search?q=<query>.
- New /search route: server component that runs searchLegacyArticles
and renders results in the same card style as /news.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
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";
|
||||
@@ -10,6 +11,14 @@ 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" },
|
||||
@@ -22,7 +31,8 @@ function parseArticleDate(dateStr: string): Date {
|
||||
}
|
||||
|
||||
export default function NewsPage({ articles }: { articles: Article[] }) {
|
||||
const allArticles = articles;
|
||||
const allArticles = Array.isArray(articles) ? articles : [];
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
@@ -30,46 +40,66 @@ export default function NewsPage({ articles }: { articles: Article[] }) {
|
||||
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) =>
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
a.excerpt.toLowerCase().includes(q) ||
|
||||
a.author.toLowerCase().includes(q) ||
|
||||
a.tags.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
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") {
|
||||
result = result.filter((a) =>
|
||||
a.tags.some((t) => t.toLowerCase() === activeCategory.toLowerCase()) ||
|
||||
a.category.toLowerCase().includes(activeCategory.toLowerCase())
|
||||
);
|
||||
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);
|
||||
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);
|
||||
result = result.filter((a) => parseArticleDate(a?.date ?? "") <= to);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
result.sort((a, b) => {
|
||||
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 a.title.localeCompare(b.title);
|
||||
if (sortBy === "za") return b.title.localeCompare(a.title);
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -220,36 +250,39 @@ export default function NewsPage({ articles }: { articles: Article[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{filtered.map((article) => (
|
||||
<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}
|
||||
alt={article.alt}
|
||||
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}</span>
|
||||
{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>
|
||||
<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}
|
||||
</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}</span>
|
||||
<span className="text-[#444] text-[10px]">·</span>
|
||||
<span className="text-[#555] font-body text-xs">{article.readTime}</span>
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
import {
|
||||
getLegacyArticleBySlug,
|
||||
@@ -70,7 +70,10 @@ export async function generateStaticParams() {
|
||||
export default async function NewsArticlePage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const { article, related } = await loadArticle(slug);
|
||||
if (!article) notFound();
|
||||
if (!article) {
|
||||
// Unknown slug — fall back to the news index instead of 404.
|
||||
redirect("/news");
|
||||
}
|
||||
|
||||
const articleSchema = {
|
||||
"@context": "https://schema.org",
|
||||
|
||||
Reference in New Issue
Block a user