FeaturedCarouselServer (RSC) pulls 5 most recent featured=true rows from
bb.ai_rewritten_articles UNION bb.wp_imported_posts, renders into the
client FeaturedCarousel component: 4s auto-advance (pause on hover),
chevron + dot nav + NN/NN counter, [ID xxxx] / [CATEGORY] badges,
serif headline, lede, optional neural-summary box with corner brackets +
confidence + entity chips, byline with initials avatar. Replaces the
FeaturedBento on the homepage.
Seeded bb.wp_imported_posts.featured = TRUE on 5 recent posts so the
carousel renders out-of-the-box (Amagi, Riedel, LiveU BroadcastAsia,
Cobalt Best-of-Show, Leader MPTS).
Phase 2 UI:
EventTagPicker (client) — chip multi-select reading bb.events,
writing to bb.article_events via
/api/admin/review-queue/[id]/events.
article_table=ai_rewritten for now;
future submission-form integration will
cover other tables.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.3 KiB
TypeScript
102 lines
3.3 KiB
TypeScript
import { createClient } from "@supabase/supabase-js";
|
|
import { rewriteLegacyImageUrl } from "@/lib/legacy-image";
|
|
import FeaturedCarousel from "./FeaturedCarousel";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function initials(name: string): string {
|
|
const parts = name.split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "??";
|
|
const first = parts[0][0] || "";
|
|
const last = parts.length > 1 ? parts[parts.length - 1][0] || "" : "";
|
|
return (first + last).toUpperCase();
|
|
}
|
|
|
|
function readTime(text: string | null | undefined): number {
|
|
if (!text) return 1;
|
|
const words = text.replace(/<[^>]+>/g, " ").split(/\s+/).filter(Boolean).length;
|
|
return Math.max(1, Math.round(words / 200));
|
|
}
|
|
|
|
function agoMinutes(iso: string | null): number {
|
|
if (!iso) return 0;
|
|
return Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 60000));
|
|
}
|
|
|
|
export default async function FeaturedCarouselServer() {
|
|
const sb = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
db: { schema: (process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb") as "public" },
|
|
auth: { persistSession: false },
|
|
}
|
|
);
|
|
|
|
const [wp, ai] = await Promise.all([
|
|
sb
|
|
.from("wp_imported_posts")
|
|
.select("wp_id, wp_slug, title, excerpt, content, featured_image, author_name, category, wp_published_at, neural_summary")
|
|
.eq("status", "published")
|
|
.eq("featured", true)
|
|
.order("wp_published_at", { ascending: false })
|
|
.limit(5),
|
|
sb
|
|
.from("ai_rewritten_articles")
|
|
.select("id, slug, title, excerpt, body, category, published_at, neural_summary, persona:ai_personas(name, beat)")
|
|
.eq("status", "published")
|
|
.eq("featured", true)
|
|
.order("published_at", { ascending: false, nullsFirst: false })
|
|
.limit(5),
|
|
]);
|
|
|
|
const slides: any[] = [];
|
|
|
|
for (const r of (ai.data || []) as any[]) {
|
|
const persona = Array.isArray(r.persona) ? r.persona[0] : r.persona;
|
|
const author = persona?.name || "Staff Reporter";
|
|
slides.push({
|
|
id: r.id,
|
|
slug: r.slug,
|
|
title: r.title,
|
|
excerpt: r.excerpt || "",
|
|
image: null,
|
|
category: r.category || "News",
|
|
author,
|
|
author_initials: initials(author),
|
|
author_beat: persona?.beat || null,
|
|
date: r.published_at,
|
|
read_min: readTime(r.body),
|
|
ago_min: agoMinutes(r.published_at),
|
|
neural_summary: r.neural_summary || null,
|
|
});
|
|
}
|
|
|
|
for (const r of (wp.data || []) as any[]) {
|
|
const image = r.featured_image ? rewriteLegacyImageUrl(r.featured_image) : null;
|
|
const author = r.author_name || "Staff Reporter";
|
|
slides.push({
|
|
id: String(r.wp_id),
|
|
slug: r.wp_slug,
|
|
title: r.title,
|
|
excerpt: r.excerpt || "",
|
|
image,
|
|
category: r.category || "News",
|
|
author,
|
|
author_initials: initials(author),
|
|
author_beat: null,
|
|
date: r.wp_published_at,
|
|
read_min: readTime(r.content),
|
|
ago_min: agoMinutes(r.wp_published_at),
|
|
neural_summary: r.neural_summary || null,
|
|
});
|
|
}
|
|
|
|
// Cap to 5 most recent across both sources
|
|
slides.sort((a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime());
|
|
const top5 = slides.slice(0, 5);
|
|
|
|
if (top5.length === 0) return null;
|
|
return <FeaturedCarousel slides={top5} />;
|
|
}
|