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,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import ArticleDetailPage from "./ArticleDetailClient";
|
import ArticleDetailPage from "./ArticleDetailClient";
|
||||||
import type { Article } from "@/lib/articles/types";
|
import type { Article } from "@/lib/articles/types";
|
||||||
import {
|
import {
|
||||||
@@ -66,7 +66,11 @@ export async function generateStaticParams() {
|
|||||||
export default async function ArticlePage({ params }: PageProps) {
|
export default async function ArticlePage({ params }: PageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { article, related } = await loadArticle(slug);
|
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 = {
|
const articleSchema = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|||||||
42
src/app/error.tsx
Normal file
42
src/app/error.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0d0d0d] text-[#e0e0e0] p-6">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<h2 className="font-heading text-2xl mb-3">Something went wrong</h2>
|
||||||
|
<p className="text-[#888] text-sm mb-4 font-body">
|
||||||
|
We hit an error rendering this page. Try again, or head back to the home page.
|
||||||
|
</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p className="text-[#444] text-xs font-mono mb-4">ref: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => reset()}
|
||||||
|
className="px-4 py-2 bg-[#3b82f6] text-white text-sm rounded-sm hover:bg-[#2563eb] transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/home-page"
|
||||||
|
className="px-4 py-2 border border-[#333] text-sm rounded-sm hover:border-[#3b82f6] transition-colors"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/global-error.tsx
Normal file
33
src/app/global-error.tsx
Normal file
@@ -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 (
|
||||||
|
<html>
|
||||||
|
<body style={{ background: "#0d0d0d", color: "#e0e0e0", fontFamily: "system-ui", padding: 24, minHeight: "100vh" }}>
|
||||||
|
<div style={{ maxWidth: 480, margin: "10vh auto", textAlign: "center" }}>
|
||||||
|
<h2 style={{ fontSize: 22, marginBottom: 12 }}>Something went wrong</h2>
|
||||||
|
<p style={{ marginBottom: 12, color: "#888" }}>BroadcastBeat hit an unexpected error.</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p style={{ fontSize: 12, opacity: 0.5, marginBottom: 16, fontFamily: "monospace" }}>ref: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => reset()}
|
||||||
|
style={{ padding: "8px 16px", background: "#3b82f6", color: "white", border: 0, cursor: "pointer", borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import AppImage from "@/components/ui/AppImage";
|
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"];
|
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 = [
|
const SORT_OPTIONS = [
|
||||||
{ value: "newest", label: "Newest First" },
|
{ value: "newest", label: "Newest First" },
|
||||||
{ value: "oldest", label: "Oldest First" },
|
{ value: "oldest", label: "Oldest First" },
|
||||||
@@ -22,7 +31,8 @@ function parseArticleDate(dateStr: string): Date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NewsPage({ articles }: { articles: Article[] }) {
|
export default function NewsPage({ articles }: { articles: Article[] }) {
|
||||||
const allArticles = articles;
|
const allArticles = Array.isArray(articles) ? articles : [];
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeCategory, setActiveCategory] = useState("All");
|
const [activeCategory, setActiveCategory] = useState("All");
|
||||||
@@ -30,46 +40,66 @@ export default function NewsPage({ articles }: { articles: Article[] }) {
|
|||||||
const [dateTo, setDateTo] = useState("");
|
const [dateTo, setDateTo] = useState("");
|
||||||
const [sortBy, setSortBy] = useState("newest");
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = [...allArticles];
|
let result = [...allArticles];
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter((a) => {
|
||||||
(a) =>
|
const title = (a?.title ?? "").toLowerCase();
|
||||||
a.title.toLowerCase().includes(q) ||
|
const excerpt = (a?.excerpt ?? "").toLowerCase();
|
||||||
a.excerpt.toLowerCase().includes(q) ||
|
const author = (a?.author ?? "").toLowerCase();
|
||||||
a.author.toLowerCase().includes(q) ||
|
const tags = Array.isArray(a?.tags) ? a.tags : [];
|
||||||
a.tags.some((t) => t.toLowerCase().includes(q))
|
return (
|
||||||
);
|
title.includes(q) ||
|
||||||
|
excerpt.includes(q) ||
|
||||||
|
author.includes(q) ||
|
||||||
|
tags.some((t) => (t ?? "").toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category filter
|
// Category filter
|
||||||
if (activeCategory !== "All") {
|
if (activeCategory !== "All") {
|
||||||
result = result.filter((a) =>
|
const cat = activeCategory.toLowerCase();
|
||||||
a.tags.some((t) => t.toLowerCase() === activeCategory.toLowerCase()) ||
|
result = result.filter((a) => {
|
||||||
a.category.toLowerCase().includes(activeCategory.toLowerCase())
|
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
|
// Date range filter
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
const from = new Date(dateFrom);
|
const from = new Date(dateFrom);
|
||||||
result = result.filter((a) => parseArticleDate(a.date) >= from);
|
result = result.filter((a) => parseArticleDate(a?.date ?? "") >= from);
|
||||||
}
|
}
|
||||||
if (dateTo) {
|
if (dateTo) {
|
||||||
const to = new Date(dateTo);
|
const to = new Date(dateTo);
|
||||||
to.setHours(23, 59, 59, 999);
|
to.setHours(23, 59, 59, 999);
|
||||||
result = result.filter((a) => parseArticleDate(a.date) <= to);
|
result = result.filter((a) => parseArticleDate(a?.date ?? "") <= to);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (sortBy === "newest") return parseArticleDate(b.date).getTime() - parseArticleDate(a.date).getTime();
|
const ta = (a?.title ?? "");
|
||||||
if (sortBy === "oldest") return parseArticleDate(a.date).getTime() - parseArticleDate(b.date).getTime();
|
const tb = (b?.title ?? "");
|
||||||
if (sortBy === "az") return a.title.localeCompare(b.title);
|
if (sortBy === "newest") return parseArticleDate(b?.date ?? "").getTime() - parseArticleDate(a?.date ?? "").getTime();
|
||||||
if (sortBy === "za") return b.title.localeCompare(a.title);
|
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 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,36 +250,39 @@ export default function NewsPage({ articles }: { articles: Article[] }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{filtered.map((article) => (
|
{filtered.map((article) => {
|
||||||
<Link
|
if (!article?.slug) return null;
|
||||||
key={article.slug}
|
return (
|
||||||
href={`/news/${article.slug}`}
|
<Link
|
||||||
className="flex gap-4 py-5 border-b border-[#222] group hover:bg-[#111] transition-colors px-2 -mx-2">
|
key={article.slug}
|
||||||
<div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm">
|
href={`/news/${article.slug}`}
|
||||||
<AppImage
|
className="flex gap-4 py-5 border-b border-[#222] group hover:bg-[#111] transition-colors px-2 -mx-2">
|
||||||
src={article.image}
|
<div className="flex-shrink-0 w-[160px] h-[105px] relative overflow-hidden rounded-sm">
|
||||||
alt={article.alt}
|
<AppImage
|
||||||
fill
|
src={article.image || "/assets/images/article-placeholder.jpg"}
|
||||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
alt={article.alt || article.title || "BroadcastBeat article"}
|
||||||
sizes="160px"
|
fill
|
||||||
/>
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
</div>
|
sizes="160px"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-2">
|
<div className="flex-1 min-w-0">
|
||||||
{article.title}
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
</h2>
|
<span className="text-[#3b82f6] font-body text-[10px] font-bold uppercase tracking-wider">{article.category || "NEWS"}</span>
|
||||||
<p className="text-[#777] font-body text-sm leading-relaxed line-clamp-2">{article.excerpt}</p>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<h2 className="font-heading text-[#e0e0e0] text-base font-bold leading-snug mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-2">
|
||||||
<span className="text-[#555] font-body text-xs">By {article.author}</span>
|
{article.title || "Untitled"}
|
||||||
<span className="text-[#444] text-[10px]">·</span>
|
</h2>
|
||||||
<span className="text-[#555] font-body text-xs">{article.readTime}</span>
|
<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>
|
||||||
</div>
|
</Link>
|
||||||
</Link>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Article } from "@/lib/articles/types";
|
import type { Article } from "@/lib/articles/types";
|
||||||
import {
|
import {
|
||||||
getLegacyArticleBySlug,
|
getLegacyArticleBySlug,
|
||||||
@@ -70,7 +70,10 @@ export async function generateStaticParams() {
|
|||||||
export default async function NewsArticlePage({ params }: PageProps) {
|
export default async function NewsArticlePage({ params }: PageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { article, related } = await loadArticle(slug);
|
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 = {
|
const articleSchema = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|||||||
100
src/app/search/page.tsx
Normal file
100
src/app/search/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<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">Search</span>
|
||||||
|
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-heading text-white text-3xl font-bold">
|
||||||
|
{query ? `Results for "${query}"` : "Search BroadcastBeat"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#777] font-body text-sm mt-2">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="max-w-container mx-auto px-4 py-8">
|
||||||
|
{query && results.length === 0 && (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<p className="text-[#555] font-body text-sm mb-3">No articles match your search.</p>
|
||||||
|
<Link href="/news" className="text-[#3b82f6] text-xs font-body hover:underline">
|
||||||
|
Browse all news →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-0">
|
||||||
|
{results.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>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import AppImage from "@/components/ui/AppImage";
|
import AppImage from "@/components/ui/AppImage";
|
||||||
import {
|
import {
|
||||||
LinkedInIcon,
|
LinkedInIcon,
|
||||||
@@ -68,10 +69,18 @@ const navLinks: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const router = useRouter();
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
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<string | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null);
|
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
@@ -440,6 +449,12 @@ export default function Header() {
|
|||||||
onChange={(e) => setSearchQuery(e?.target?.value)}
|
onChange={(e) => setSearchQuery(e?.target?.value)}
|
||||||
onFocus={() => setSearchFocused(true)}
|
onFocus={() => setSearchFocused(true)}
|
||||||
onBlur={() => setSearchFocused(false)}
|
onBlur={() => setSearchFocused(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="search-input search-input-enhanced pr-16"
|
className="search-input search-input-enhanced pr-16"
|
||||||
aria-label="Search articles (press / to focus)"
|
aria-label="Search articles (press / to focus)"
|
||||||
/>
|
/>
|
||||||
@@ -456,18 +471,24 @@ export default function Header() {
|
|||||||
<span className="search-kbd-hint" aria-hidden="true">/</span>
|
<span className="search-kbd-hint" aria-hidden="true">/</span>
|
||||||
)}
|
)}
|
||||||
{(searchQuery || searchFocused) && (
|
{(searchQuery || searchFocused) && (
|
||||||
<SearchIcon
|
<button
|
||||||
size={13}
|
type="button"
|
||||||
strokeWidth={1.75}
|
onClick={submitSearch}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#3b82f6] pointer-events-none transition-colors"
|
aria-label="Submit search"
|
||||||
/>
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#3b82f6] hover:text-[#60a5fa] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<SearchIcon size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
{!searchQuery && !searchFocused && (
|
{!searchQuery && !searchFocused && (
|
||||||
<SearchIcon
|
<button
|
||||||
size={13}
|
type="button"
|
||||||
strokeWidth={1.75}
|
onClick={() => searchRef.current?.focus()}
|
||||||
className="absolute right-8 top-1/2 -translate-y-1/2 text-[#666] pointer-events-none"
|
aria-label="Focus search"
|
||||||
/>
|
className="absolute right-8 top-1/2 -translate-y-1/2 text-[#666] hover:text-[#3b82f6] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<SearchIcon size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -585,14 +606,25 @@ export default function Header() {
|
|||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search articles..."
|
placeholder="Search articles..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e?.target?.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="search-input search-input-enhanced w-full pr-8"
|
className="search-input search-input-enhanced w-full pr-8"
|
||||||
aria-label="Search articles mobile"
|
aria-label="Search articles mobile"
|
||||||
/>
|
/>
|
||||||
<SearchIcon
|
<button
|
||||||
size={13}
|
type="button"
|
||||||
strokeWidth={1.75}
|
onClick={submitSearch}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#666] pointer-events-none"
|
aria-label="Submit search"
|
||||||
/>
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#666] hover:text-[#3b82f6] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<SearchIcon size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -170,6 +170,28 @@ export async function getLegacyArticlesBySection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchLegacyArticles(
|
||||||
|
query: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<Article[]> {
|
||||||
|
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<string[]> {
|
export async function getLegacyRecentSlugs(limit = 200): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const { data } = await client()
|
const { data } = await client()
|
||||||
|
|||||||
Reference in New Issue
Block a user