delete sampleArticles seed; serve all sections from bb.wp_imported_posts (DB)
Eliminates 72 hardcoded rocket.new image refs in one shot. Section pages (/gear, /technology, /show-coverage, /news) now render from bb.wp_imported_posts via getLegacyArticlesBySection(section), which maps each non-news section to a curated tag set (e.g. gear -> Audio, Cameras, lighting, Blackmagic Design, monitoring; technology -> AI, Streaming, OTT, IP, Cloud, AV; show-coverage -> NAB, IBC, BroadcastAsia). Also: - src/lib/articles/types.ts: Article interface lifted out of the deleted seed file. Type-only consumers (NewsArticleDetailClient, ArticleDetailClient, legacy-source) re-imported from here. - /news (client component) now wrapped: server fetches articles from DB and passes to NewsPageClient. Filter UI unchanged. - generateStaticParams on /news/[slug] and /articles/[slug] no longer references seed slugs; pre-renders 50 most-recent imported slugs and relies on dynamicParams + revalidate=3600 for the rest. - sitemap.ts now sources article URLs from getLegacyRecentSitemapEntries() (up to 5000 most-recent imported posts). Default BASE_URL fallback updated from the rocket.new placeholder to bb-staging.onsethost.com. - src/app/api/ai/article-suggestions/route.ts now pulls candidates from the DB (top 100 recent news) and resolves AI-returned slugs via DB lookup if not in the candidate window. Inline rocket.new image refs in homepage components (ArticleFeed, FeaturedBento) are unchanged in this commit; those are inline seed arrays in the components, not imports of sampleArticles.
This commit is contained in:
@@ -1,22 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { hybridAI } from '@/lib/ai/hybridRouter';
|
||||
import { sampleArticles } from '@/lib/articles/sampleArticles';
|
||||
import {
|
||||
getLegacyArticlesBySection,
|
||||
getLegacyArticleBySlug,
|
||||
} from '@/lib/articles/legacy-source';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { interests, readingHistory } = await request.json();
|
||||
|
||||
// Build a compact article index for the AI to reason over
|
||||
const articleIndex = sampleArticles
|
||||
.filter((a) => a.section === 'news')
|
||||
.map((a) => ({
|
||||
slug: a.slug,
|
||||
title: a.title,
|
||||
excerpt: a.excerpt,
|
||||
tags: a.tags,
|
||||
category: a.category,
|
||||
date: a.date,
|
||||
}));
|
||||
// Pull a recent slice of news for the AI to choose from. Cap at 100 so the
|
||||
// prompt stays bounded.
|
||||
const news = await getLegacyArticlesBySection('news', 100);
|
||||
|
||||
const articleIndex = news.map((a) => ({
|
||||
slug: a.slug,
|
||||
title: a.title,
|
||||
excerpt: a.excerpt,
|
||||
tags: a.tags,
|
||||
category: a.category,
|
||||
date: a.date,
|
||||
}));
|
||||
|
||||
const systemPrompt = `You are a personalized content recommendation engine for BroadcastBeat, a broadcast engineering news platform.
|
||||
Given a reader's topic interests and reading history, select the 4 most relevant articles from the provided article list.
|
||||
@@ -36,45 +40,38 @@ Return the 4 most relevant article slugs as a JSON array.`;
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
{
|
||||
maxTokens: 200,
|
||||
temperature: 0.3,
|
||||
priority: 4,
|
||||
}
|
||||
{ maxTokens: 200, temperature: 0.3, priority: 4 }
|
||||
);
|
||||
|
||||
const content = result.text;
|
||||
|
||||
let slugs: string[] = [];
|
||||
try {
|
||||
// Extract JSON array from response (model may wrap it in text)
|
||||
const match = content.match(/\[[\s\S]*?\]/);
|
||||
slugs = match ? JSON.parse(match[0]) : JSON.parse(content.trim());
|
||||
} catch {
|
||||
// Fallback: extract slugs with regex
|
||||
const matches = content.match(/"([^"]+)"/g);
|
||||
slugs = matches ? matches.map((m: string) => m.replace(/"/g, '')).slice(0, 4) : [];
|
||||
}
|
||||
|
||||
// Resolve full article objects
|
||||
const suggested = slugs
|
||||
.map((slug: string) => sampleArticles.find((a) => a.slug === slug))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
// Resolve from the candidate set first; if a slug wasn't in the recent
|
||||
// window, look it up via DB so AI hallucinations or older slugs still resolve.
|
||||
const suggested = (
|
||||
await Promise.all(
|
||||
slugs.slice(0, 4).map(async (slug: string) =>
|
||||
news.find((a) => a.slug === slug) || (await getLegacyArticleBySlug(slug))
|
||||
)
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
// Fallback: return latest news if AI returned nothing useful
|
||||
if (suggested.length === 0) {
|
||||
const fallback = sampleArticles
|
||||
.filter((a) => a.section === 'news')
|
||||
.slice(0, 4);
|
||||
return NextResponse.json({ suggestions: fallback });
|
||||
return NextResponse.json({ suggestions: news.slice(0, 4) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions: suggested });
|
||||
} catch (error) {
|
||||
console.error('Article suggestions error:', error);
|
||||
// Graceful fallback
|
||||
const fallback = sampleArticles.filter((a) => a.section === 'news').slice(0, 4);
|
||||
const fallback = await getLegacyArticlesBySection('news', 4);
|
||||
return NextResponse.json({ suggestions: fallback });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import AppImage from "@/components/ui/AppImage";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/sampleArticles";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
|
||||
// DO NOT OVERRIDE — ArticleDetailClient interface and session tracking
|
||||
interface ArticleDetailClientProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import ArticleDetailPage from "./ArticleDetailClient";
|
||||
import { sampleArticles, getRelatedArticles, type Article } from "@/lib/articles/sampleArticles";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
import {
|
||||
getLegacyArticleBySlug,
|
||||
getLegacyRecentSlugs,
|
||||
@@ -22,10 +22,6 @@ async function loadArticle(slug: string): Promise<{
|
||||
article: Article | null;
|
||||
related: Article[];
|
||||
}> {
|
||||
const seed = sampleArticles.find((a) => a.slug === slug);
|
||||
if (seed) {
|
||||
return { article: seed, related: getRelatedArticles(slug, 3) };
|
||||
}
|
||||
const imported = await getLegacyArticleBySlug(slug);
|
||||
if (!imported) return { article: null, related: [] };
|
||||
const related = await getLegacyRelatedArticles(slug, imported.category, 3);
|
||||
@@ -63,9 +59,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const seedSlugs = sampleArticles.map((a) => a.slug);
|
||||
const recent = await getLegacyRecentSlugs(50);
|
||||
return Array.from(new Set([...seedSlugs, ...recent])).map((slug) => ({ slug }));
|
||||
return recent.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export default async function ArticlePage({ params }: PageProps) {
|
||||
|
||||
@@ -4,7 +4,9 @@ import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import { getArticlesBySection } from "@/lib/articles/sampleArticles";
|
||||
import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
|
||||
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Broadcast Gear & Reviews — BroadcastBeat",
|
||||
@@ -18,8 +20,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function GearPage() {
|
||||
const articles = getArticlesBySection("gear");
|
||||
export default async function GearPage() {
|
||||
const articles = await getLegacyArticlesBySection("gear");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
||||
293
src/app/news/NewsPageClient.tsx
Normal file
293
src/app/news/NewsPageClient.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
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"];
|
||||
|
||||
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 = articles;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [sortBy, setSortBy] = useState("newest");
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (activeCategory !== "All") {
|
||||
result = result.filter((a) =>
|
||||
a.tags.some((t) => t.toLowerCase() === activeCategory.toLowerCase()) ||
|
||||
a.category.toLowerCase().includes(activeCategory.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
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);
|
||||
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) => (
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AdSlot from "@/components/AdSlot";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Article } from "@/lib/articles/sampleArticles";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
|
||||
interface NewsArticleDetailClientProps {
|
||||
article: Article;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { sampleArticles, getRelatedArticles, type Article } from "@/lib/articles/sampleArticles";
|
||||
import type { Article } from "@/lib/articles/types";
|
||||
import {
|
||||
getLegacyArticleBySlug,
|
||||
getLegacyRecentSlugs,
|
||||
@@ -23,17 +23,11 @@ const SITE_URL =
|
||||
async function loadArticle(slug: string): Promise<{
|
||||
article: Article | null;
|
||||
related: Article[];
|
||||
source: "seed" | "imported";
|
||||
}> {
|
||||
// Seed (sampleArticles) wins for routes already curated.
|
||||
const seed = sampleArticles.find((a) => a.slug === slug && a.section === "news");
|
||||
if (seed) {
|
||||
return { article: seed, related: getRelatedArticles(slug, 6), source: "seed" };
|
||||
}
|
||||
const imported = await getLegacyArticleBySlug(slug);
|
||||
if (!imported) return { article: null, related: [], source: "imported" };
|
||||
if (!imported) return { article: null, related: [] };
|
||||
const related = await getLegacyRelatedArticles(slug, imported.category, 6);
|
||||
return { article: imported, related, source: "imported" };
|
||||
return { article: imported, related };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
@@ -67,13 +61,10 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
// Build-time: only seed articles + the most-recent imported slugs (cap to keep
|
||||
// build fast). Anything else is on-demand ISR via dynamicParams=true.
|
||||
const seedSlugs = sampleArticles
|
||||
.filter((a) => a.section === "news")
|
||||
.map((a) => a.slug);
|
||||
// Build-time: pre-render the most-recent imported slugs (cap to keep the
|
||||
// build fast). Everything else is on-demand ISR via dynamicParams=true.
|
||||
const recent = await getLegacyRecentSlugs(50);
|
||||
return Array.from(new Set([...seedSlugs, ...recent])).map((slug) => ({ slug }));
|
||||
return recent.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
export default async function NewsArticlePage({ params }: PageProps) {
|
||||
|
||||
@@ -1,293 +1,17 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import NewsPageClient from "./NewsPageClient";
|
||||
import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import { getArticlesBySection } from "@/lib/articles/sampleArticles";
|
||||
import AISuggestedArticles from "@/components/AISuggestedArticles";
|
||||
export const revalidate = 1800;
|
||||
|
||||
const CATEGORIES = ["All", "Cloud", "AI", "IP Workflows", "Live Production", "Streaming", "Audio", "Cameras", "NAB 2026", "EAS", "Sports Broadcasting", "Ad Tech"];
|
||||
export const metadata: Metadata = {
|
||||
title: "Latest Broadcast News — BroadcastBeat",
|
||||
description:
|
||||
"Breaking news and industry updates from the world of broadcast engineering, IP/cloud workflows, streaming, AI, and live production.",
|
||||
alternates: { canonical: "/news" },
|
||||
};
|
||||
|
||||
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() {
|
||||
const allArticles = getArticlesBySection("news");
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [sortBy, setSortBy] = useState("newest");
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (activeCategory !== "All") {
|
||||
result = result.filter((a) =>
|
||||
a.tags.some((t) => t.toLowerCase() === activeCategory.toLowerCase()) ||
|
||||
a.category.toLowerCase().includes(activeCategory.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
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);
|
||||
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) => (
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
export default async function NewsPage() {
|
||||
const articles = await getLegacyArticlesBySection("news", 200);
|
||||
return <NewsPageClient articles={articles} />;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import { getArticlesBySection } from "@/lib/articles/sampleArticles";
|
||||
import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
|
||||
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NAB Show, IBC & Broadcast Event Coverage — BroadcastBeat",
|
||||
@@ -20,8 +22,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShowCoveragePage() {
|
||||
const articles = getArticlesBySection("show-coverage");
|
||||
export default async function ShowCoveragePage() {
|
||||
const articles = await getLegacyArticlesBySection("show-coverage");
|
||||
const nabArticles = articles.filter((a) => a.tags.includes("NAB 2026"));
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { sampleArticles } from '@/lib/articles/sampleArticles';
|
||||
import { getLegacyRecentSitemapEntries } from '@/lib/articles/legacy-source';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastb5322.builtwithrocket.new';
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://bb-staging.onsethost.com';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// Static pages
|
||||
@@ -23,13 +23,26 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
{ url: `${BASE_URL}/terms`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||
];
|
||||
|
||||
// Dynamic article pages — all published articles
|
||||
const articlePages: MetadataRoute.Sitemap = sampleArticles.map((article) => ({
|
||||
url: `${BASE_URL}/articles/${article.slug}`,
|
||||
lastModified: new Date(article.date) || new Date(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
// Dynamic article pages — sourced from bb.wp_imported_posts (DB).
|
||||
const articleEntries = await getLegacyRecentSitemapEntries(5000);
|
||||
const articlePages: MetadataRoute.Sitemap = articleEntries.flatMap(({ slug, date }) => {
|
||||
const lastModified = new Date(date);
|
||||
const safe = isNaN(lastModified.getTime()) ? new Date() : lastModified;
|
||||
return [
|
||||
{
|
||||
url: `${BASE_URL}/news/${slug}`,
|
||||
lastModified: safe,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/articles/${slug}`,
|
||||
lastModified: safe,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Attempt to ping Google Search Console (fire-and-forget, non-blocking)
|
||||
const sitemapUrl = encodeURIComponent(`${BASE_URL}/sitemap.xml`);
|
||||
|
||||
@@ -4,7 +4,9 @@ import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import AppImage from "@/components/ui/AppImage";
|
||||
import { getArticlesBySection } from "@/lib/articles/sampleArticles";
|
||||
import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
|
||||
|
||||
export const revalidate = 1800;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Broadcast Technology Deep Dives — BroadcastBeat",
|
||||
@@ -20,8 +22,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function TechnologyPage() {
|
||||
const articles = getArticlesBySection("technology");
|
||||
export default async function TechnologyPage() {
|
||||
const articles = await getLegacyArticlesBySection("technology");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
||||
Reference in New Issue
Block a user