From 70e2aebb83828926123d205823da16005da1502f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 14 May 2026 16:35:18 +0000 Subject: [PATCH] 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=. - 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) --- src/app/articles/[slug]/page.tsx | 8 +- src/app/error.tsx | 42 ++++++++++ src/app/global-error.tsx | 33 ++++++++ src/app/news/NewsPageClient.tsx | 127 +++++++++++++++++++----------- src/app/news/[slug]/page.tsx | 7 +- src/app/search/page.tsx | 100 +++++++++++++++++++++++ src/components/Header.tsx | 62 +++++++++++---- src/lib/articles/legacy-source.ts | 22 ++++++ 8 files changed, 335 insertions(+), 66 deletions(-) create mode 100644 src/app/error.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/app/search/page.tsx diff --git a/src/app/articles/[slug]/page.tsx b/src/app/articles/[slug]/page.tsx index 326d952..c3a5460 100644 --- a/src/app/articles/[slug]/page.tsx +++ b/src/app/articles/[slug]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; +import { redirect } from "next/navigation"; import ArticleDetailPage from "./ArticleDetailClient"; import type { Article } from "@/lib/articles/types"; import { @@ -66,7 +66,11 @@ export async function generateStaticParams() { export default async function ArticlePage({ params }: PageProps) { const { slug } = await params; const { article, related } = await loadArticle(slug); - if (!article) notFound(); + if (!article) { + // Slug isn't in any of our article tables — send the reader to the news + // index so a click from the homepage/ticker doesn't dead-end on 404. + redirect("/news"); + } const articleSchema = { "@context": "https://schema.org", diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..58c03e6 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,42 @@ +"use client"; +import { useEffect } from "react"; +import Link from "next/link"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[route error]", error); + }, [error]); + return ( +
+
+

Something went wrong

+

+ We hit an error rendering this page. Try again, or head back to the home page. +

+ {error?.digest && ( +

ref: {error.digest}

+ )} +
+ + + Home + +
+
+
+ ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..92c5ab6 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,33 @@ +"use client"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("[global error]", error); + }, [error]); + return ( + + +
+

Something went wrong

+

BroadcastBeat hit an unexpected error.

+ {error?.digest && ( +

ref: {error.digest}

+ )} + +
+ + + ); +} diff --git a/src/app/news/NewsPageClient.tsx b/src/app/news/NewsPageClient.tsx index a9eec88..1cd31e3 100644 --- a/src/app/news/NewsPageClient.tsx +++ b/src/app/news/NewsPageClient.tsx @@ -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[] }) { ) : (
- {filtered.map((article) => ( - -
- -
-
-
- {article.category} + {filtered.map((article) => { + if (!article?.slug) return null; + return ( + +
+
-

- {article.title} -

-

{article.excerpt}

-
- By {article.author} - · - {article.readTime} +
+
+ {article.category || "NEWS"} +
+

+ {article.title || "Untitled"} +

+

{article.excerpt || ""}

+
+ By {article.author || "Staff Reporter"} + · + {article.readTime || ""} +
-
- - ))} + + ); + })}
)}
diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index 81af3b8..506bc71 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -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", diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..66cd496 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,100 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import AppImage from "@/components/ui/AppImage"; +import { searchLegacyArticles } from "@/lib/articles/legacy-source"; + +export const revalidate = 60; + +export const metadata: Metadata = { + title: "Search — BroadcastBeat", + description: + "Search BroadcastBeat for broadcast engineering news, gear reviews, show coverage, and technology insights.", + alternates: { canonical: "/search" }, +}; + +interface SearchPageProps { + searchParams: Promise<{ q?: string }>; +} + +export default async function SearchPage({ searchParams }: SearchPageProps) { + const { q } = await searchParams; + const query = (q ?? "").trim(); + const results = query ? await searchLegacyArticles(query, 100) : []; + + return ( +
+
+
+
+
+ Search +
+
+

+ {query ? `Results for "${query}"` : "Search BroadcastBeat"} +

+

+ {query + ? `${results.length} article${results.length === 1 ? "" : "s"} found across news, gear, technology, and show coverage.` + : "Type in the header search box to find articles."} +

+
+
+ +
+ {query && results.length === 0 && ( +
+

No articles match your search.

+ + Browse all news → + +
+ )} + +
+ {results.map((article) => { + if (!article?.slug) return null; + return ( + +
+ +
+
+
+ + {article.category || "NEWS"} + +
+

+ {article.title || "Untitled"} +

+

+ {article.excerpt || ""} +

+
+ By {article.author || "Staff Reporter"} + · + {article.readTime || ""} +
+
+ + ); + })} +
+
+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 05ccae6..89cb07e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useRef } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import AppImage from "@/components/ui/AppImage"; import { LinkedInIcon, @@ -68,10 +69,18 @@ const navLinks: NavItem[] = [ ]; export default function Header() { + const router = useRouter(); const [scrolled, setScrolled] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchFocused, setSearchFocused] = useState(false); + + const submitSearch = () => { + const q = searchQuery.trim(); + if (!q) return; + setMobileOpen(false); + router.push(`/search?q=${encodeURIComponent(q)}`); + }; const [currentUserId, setCurrentUserId] = useState(null); const [currentUserEmail, setCurrentUserEmail] = useState(null); const [isAdmin, setIsAdmin] = useState(false); @@ -440,6 +449,12 @@ export default function Header() { onChange={(e) => setSearchQuery(e?.target?.value)} onFocus={() => setSearchFocused(true)} onBlur={() => setSearchFocused(false)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitSearch(); + } + }} className="search-input search-input-enhanced pr-16" aria-label="Search articles (press / to focus)" /> @@ -456,18 +471,24 @@ export default function Header() { )} {(searchQuery || searchFocused) && ( - + )} {!searchQuery && !searchFocused && ( - + )}
@@ -585,14 +606,25 @@ export default function Header() { setSearchQuery(e?.target?.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitSearch(); + } + }} className="search-input search-input-enhanced w-full pr-8" aria-label="Search articles mobile" /> - + diff --git a/src/lib/articles/legacy-source.ts b/src/lib/articles/legacy-source.ts index 483d8d8..d606339 100644 --- a/src/lib/articles/legacy-source.ts +++ b/src/lib/articles/legacy-source.ts @@ -170,6 +170,28 @@ export async function getLegacyArticlesBySection( } } +export async function searchLegacyArticles( + query: string, + limit = 50, +): Promise { + const q = (query || "").trim(); + if (!q) return []; + try { + const like = `%${q.replace(/[%_]/g, " ")}%`; + const { data, error } = await client() + .from("wp_imported_posts") + .select(SELECT_COLS) + .eq("status", "published") + .or(`title.ilike.${like},excerpt.ilike.${like},author_name.ilike.${like}`) + .order("wp_published_at", { ascending: false }) + .limit(limit); + if (error || !data) return []; + return (data as ImportedPostRow[]).map((r) => rowToArticle(r, "news")); + } catch { + return []; + } +} + export async function getLegacyRecentSlugs(limit = 200): Promise { try { const { data } = await client()