import { createClient } from "@supabase/supabase-js"; import type { Article } from "./types"; import { rewriteLegacyImageUrl, cleanLegacyImages } from "@/lib/legacy-image"; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb"; function client() { return createClient(SUPABASE_URL, SUPABASE_ANON, { db: { schema: SCHEMA as "public" }, auth: { persistSession: false }, }); } interface ImportedPostRow { wp_id: number; wp_slug: string; title: string; excerpt: string | null; content: string | null; author_name: string | null; category: string | null; tags: string[] | null; featured_image: string | null; featured_image_alt: string | null; status: string; wp_published_at: string | null; } interface RewriteRow { id: string; slug: string; title: string; excerpt: string | null; body: string; category: string | null; tags: string[] | null; status: string; published_at: string | null; created_at: string; persona: PersonaJoin | PersonaJoin[] | null; } interface PersonaJoin { slug: string; name: string; beat: string | null; avatar_url: string | null; } const FALLBACK_IMAGE = "/assets/images/article-placeholder.svg"; const REWRITE_FALLBACK_IMAGE = "/assets/images/article-placeholder.svg"; type Section = Article["section"]; const SECTION_TAGS: Record = { gear: [ "Audio", "Cameras", "lighting", "Lighting", "Reviews", "Blackmagic Design", "blackmagic", "Sony", "Canon", "ARRI", "RED", "monitoring", "Test and Measurement", ], technology: [ "AI", "Streaming", "OTT", "ip", "IP", "Cloud", "AV", "audio-over-IP interfaces", "IP Workflows", "Live Production", "Cloud Production", "MAM", "Storage", "Encoding", ], "show-coverage": [ "NAB", "NAB Show", "NAB 2025", "NAB 2026", "IBC", "IBC 2025", "IBC 2026", "BroadcastAsia", "BroadcastAsia 2026", "Broadcast Asia 2026", "InfoComm", "MPTS", ], news: [], }; // Map between site sections and the AI-derived categories now stored in // bb.wp_imported_posts.category. Each section query uses these to pull // only the relevant slice of the archive — otherwise /gear and // /technology would both return everything that doesn't match // show-coverage (the old fallback). // Production-tech categories live here: // "Streaming & IP", "Cloud & Workflow", "AI & Automation", // "Live Production", "Post Production", "Broadcast Production" // Hardware/gear: // "Cameras & Capture", "Audio" const SECTION_CATEGORIES: Record = { gear: ["Cameras & Capture", "Audio"], technology: [ "Streaming & IP", "Cloud & Workflow", "AI & Automation", "Live Production", "Post Production", "Broadcast Production", ], "show-coverage": ["Show Coverage"], news: ["Industry News"], }; function authorSlug(name: string | null): string { if (!name) return "staff-reporter"; return ( name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60) || "staff-reporter" ); } function formatDate(iso: string | null): string { if (!iso) return ""; const d = new Date(iso); if (isNaN(d.getTime())) return ""; return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); } function readTime(content: string | null): string { if (!content) return "1 min read"; const text = content.replace(/<[^>]+>/g, " "); const words = text.trim().split(/\s+/).filter(Boolean).length; const minutes = Math.max(1, Math.round(words / 200)); return `${minutes} min read`; } function rowToArticle(row: ImportedPostRow, section: Section = "news"): Article { const image = row.featured_image ? rewriteLegacyImageUrl(row.featured_image) : FALLBACK_IMAGE; const content = cleanLegacyImages(row.content || ""); const author = row.author_name || "Broadcast Beat"; return { slug: row.wp_slug, section, title: row.title, excerpt: row.excerpt || "", category: (row.category || "NEWS").toUpperCase(), date: formatDate(row.wp_published_at), author, authorSlug: authorSlug(author), authorTitle: "Contributor", authorAvatar: FALLBACK_IMAGE, image, alt: row.featured_image_alt || row.title, readTime: readTime(row.content), tags: row.tags || [], content, }; } function inferSectionFromCategory(cat: string | null): Section { if (!cat) return "news"; const c = cat.toLowerCase(); if (c === "post production" || c === "post-production") return "technology"; if (c === "streaming" || c === "technology" || c === "live production") return "technology"; if (c === "sports") return "news"; if (c === "business") return "news"; return "news"; } function rewriteRowToArticle(row: RewriteRow, sectionOverride?: Section): Article { const sortedPublishedAt = row.published_at || row.created_at; const persona = Array.isArray(row.persona) ? row.persona[0] || null : row.persona || null; const author = persona?.name || "Broadcast Beat"; const personaSlug = persona?.slug || "staff-reporter"; const avatar = persona?.avatar_url || FALLBACK_IMAGE; return { slug: row.slug, section: sectionOverride || inferSectionFromCategory(row.category), title: row.title, excerpt: row.excerpt || "", category: (row.category || "NEWS").toUpperCase(), date: formatDate(sortedPublishedAt), author, authorSlug: personaSlug, authorTitle: persona?.beat ? humanBeat(persona.beat) : "Broadcast Beat", authorAvatar: avatar, image: REWRITE_FALLBACK_IMAGE, alt: row.title, readTime: readTime(row.body), tags: row.tags || [], content: row.body, }; } function humanBeat(beat: string): string { return beat .split(/[-_]/) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" ") .replace("M And A", "M&A"); } const SELECT_COLS = "wp_id,wp_slug,title,excerpt,content,author_name,category,tags,featured_image,featured_image_alt,status,wp_published_at"; const REWRITE_SELECT_COLS = "id,slug,title,excerpt,body,category,tags,status,published_at,created_at,persona:ai_personas(slug,name,beat,avatar_url)"; function rewriteOrderKey(a: RewriteRow): number { return new Date(a.published_at || a.created_at || 0).getTime(); } function legacyOrderKey(a: ImportedPostRow): number { return new Date(a.wp_published_at || 0).getTime(); } async function fetchPublishedRewrites(limit: number): Promise { try { const { data, error } = await client() .from("ai_rewritten_articles") .select(REWRITE_SELECT_COLS) .eq("status", "published") .order("published_at", { ascending: false, nullsFirst: false }) .limit(limit); if (error || !data) return []; return data as unknown as RewriteRow[]; } catch { return []; } } export async function getLegacyArticleBySlug(slug: string): Promise
{ try { const { data, error } = await client() .from("wp_imported_posts") .select(SELECT_COLS) .eq("wp_slug", slug) .eq("status", "published") .maybeSingle(); if (!error && data) return rowToArticle(data as ImportedPostRow); } catch { // fall through } // Try AI rewrites try { const { data, error } = await client() .from("ai_rewritten_articles") .select(REWRITE_SELECT_COLS) .eq("slug", slug) .eq("status", "published") .maybeSingle(); if (!error && data) return rewriteRowToArticle(data as unknown as RewriteRow); } catch { // fall through } return null; } export async function getLegacyRelatedArticles( excludeSlug: string, category: string | null, count: number, ): Promise { const collected: ImportedPostRow[] = []; const seen = new Set([excludeSlug]); // First pass: same category. Skip when no category recorded. if (category) { try { const { data } = await client() .from("wp_imported_posts") .select(SELECT_COLS) .eq("status", "published") .eq("category", category) .neq("wp_slug", excludeSlug) .order("wp_published_at", { ascending: false }) .limit(count); for (const row of (data as ImportedPostRow[] | null) || []) { if (!seen.has(row.wp_slug)) { collected.push(row); seen.add(row.wp_slug); } } } catch {} } // Fallback: top up to `count` with the latest published, excluding what we // already have. Handles tiny categories, orphan rows, and category=null. if (collected.length < count) { try { const { data } = await client() .from("wp_imported_posts") .select(SELECT_COLS) .eq("status", "published") .neq("wp_slug", excludeSlug) .order("wp_published_at", { ascending: false }) .limit(count + collected.length + 5); for (const row of (data as ImportedPostRow[] | null) || []) { if (collected.length >= count) break; if (!seen.has(row.wp_slug)) { collected.push(row); seen.add(row.wp_slug); } } } catch {} } return collected.map((r) => rowToArticle(r)); } export async function getLegacyArticlesBySection( section: Section, limit = 200, ): Promise { let legacy: ImportedPostRow[] = []; let rewrites: RewriteRow[] = []; try { let q = client() .from("wp_imported_posts") .select(SELECT_COLS) .eq("status", "published") // Google News-safe surface: only show articles that have been AI-rewritten // into original editorial. Raw press-release imports are excluded. .not("ai_rewritten_at", "is", null) .order("wp_published_at", { ascending: false }) .limit(limit); const tags = SECTION_TAGS[section]; if (tags.length > 0) q = q.overlaps("tags", tags); const { data, error } = await q; if (!error && data) legacy = data as ImportedPostRow[]; } catch { // ignore } try { let q = client() .from("ai_rewritten_articles") .select(REWRITE_SELECT_COLS) .eq("status", "published") .order("published_at", { ascending: false, nullsFirst: false }) .limit(limit); const cats = SECTION_CATEGORIES[section]; if (cats.length > 0) q = q.in("category", cats); const { data, error } = await q; if (!error && data) rewrites = data as unknown as RewriteRow[]; } catch { // ignore } const merged: Array<{ ts: number; article: Article }> = [ ...legacy.map((r) => ({ ts: legacyOrderKey(r), article: rowToArticle(r, section) })), ...rewrites.map((r) => ({ ts: rewriteOrderKey(r), article: rewriteRowToArticle(r, section) })), ]; merged.sort((a, b) => b.ts - a.ts); return merged.slice(0, limit).map((m) => m.article); } export async function getLegacyFeaturedArticles(limit = 200): Promise { try { const { data, error } = await client() .from("wp_imported_posts") .select(SELECT_COLS + ",featured") .eq("status", "published") .ilike("category", "featured") .order("featured", { ascending: false }) .order("wp_published_at", { ascending: false, nullsFirst: false }) .limit(limit); if (error || !data) return []; return (data as ImportedPostRow[]).map((r) => rowToArticle(r)); } catch { return []; } } // Default raised from 50 → 5000 because users expect search to return // every match. The 29k-article archive is small enough that title/excerpt // ilike filters stay fast even without an explicit cap. Pass a smaller // `limit` (e.g. 10) for autocomplete-style typeahead queries. export async function searchLegacyArticles( query: string, limit = 5000, ): Promise { const q = (query || "").trim(); if (!q) return []; const like = `%${q.replace(/[%_]/g, " ")}%`; let legacy: ImportedPostRow[] = []; let rewrites: RewriteRow[] = []; try { 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) legacy = data as ImportedPostRow[]; } catch { // ignore } try { const { data, error } = await client() .from("ai_rewritten_articles") .select(REWRITE_SELECT_COLS) .eq("status", "published") .or(`title.ilike.${like},excerpt.ilike.${like}`) .order("published_at", { ascending: false, nullsFirst: false }) .limit(limit); if (!error && data) rewrites = data as unknown as RewriteRow[]; } catch { // ignore } const merged: Array<{ ts: number; article: Article }> = [ ...legacy.map((r) => ({ ts: legacyOrderKey(r), article: rowToArticle(r, "news") })), ...rewrites.map((r) => ({ ts: rewriteOrderKey(r), article: rewriteRowToArticle(r) })), ]; merged.sort((a, b) => b.ts - a.ts); return merged.slice(0, limit).map((m) => m.article); } export async function getLegacyRecentSlugs(limit = 200): Promise { let legacy: string[] = []; let rewrites: string[] = []; try { const { data } = await client() .from("wp_imported_posts") .select("wp_slug") .eq("status", "published") .order("wp_published_at", { ascending: false }) .limit(limit); legacy = (data || []).map((r: { wp_slug: string }) => r.wp_slug); } catch { // ignore } try { const { data } = await client() .from("ai_rewritten_articles") .select("slug,published_at") .eq("status", "published") .order("published_at", { ascending: false, nullsFirst: false }) .limit(limit); rewrites = (data || []).map((r: { slug: string }) => r.slug); } catch { // ignore } return [...rewrites, ...legacy].slice(0, limit); } export async function getLegacyRecentSitemapEntries( limit = 25000, ): Promise<{ slug: string; date: string }[]> { const out: { slug: string; date: string }[] = []; // PostgREST caps each response at max_rows (1000 by default), so we // page with explicit Range headers via .range() until either limit is // hit OR we get a short page back (end of data). const PAGE = 1000; async function pageThrough( table: string, selectCols: string, orderCol: string, slugCol: string, dateCols: string[], cap: number, ) { let from = 0; while (from < cap) { const to = Math.min(from + PAGE - 1, cap - 1); let q = client().from(table).select(selectCols).eq("status", "published"); q = q.order(orderCol, { ascending: false, nullsFirst: false }); const { data, error } = await q.range(from, to); if (error) break; const rows = (data || []) as any[]; if (rows.length === 0) break; for (const r of rows) { const slug = r[slugCol]; if (!slug) continue; let date: string | null = null; for (const c of dateCols) { if (r[c]) { date = r[c]; break; } } out.push({ slug, date: date || new Date().toISOString() }); } if (rows.length < PAGE) break; from += rows.length; } } try { await pageThrough( "wp_imported_posts", "wp_slug,wp_published_at", "wp_published_at", "wp_slug", ["wp_published_at"], limit, ); } catch { // ignore } try { await pageThrough( "ai_rewritten_articles", "slug,published_at,created_at", "published_at", "slug", ["published_at", "created_at"], limit, ); } catch { // ignore } out.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return out.slice(0, limit); } export async function getRecentAiRewrites(limit = 20): Promise { const rows = await fetchPublishedRewrites(limit); return rows.map((r) => rewriteRowToArticle(r)); } export async function getLatestImportedArticles( limit = 100, offset = 0, ): Promise<{ items: Article[]; total: number }> { try { const { data, error, count } = await client() .from("wp_imported_posts") .select(SELECT_COLS, { count: "exact" }) .eq("status", "published") .order("wp_published_at", { ascending: false, nullsFirst: false }) .range(offset, offset + limit - 1); if (error || !data) return { items: [], total: 0 }; return { items: (data as ImportedPostRow[]).map((r) => rowToArticle(r)), total: count ?? data.length, }; } catch { return { items: [], total: 0 }; } }