From 4546d5be3b2d03222b1c9afbd7fddb12bba7d29e Mon Sep 17 00:00:00 2001 From: "Claude (Phase B)" Date: Fri, 15 May 2026 08:20:49 +0000 Subject: [PATCH] Phase 6C featured carousel + Phase 2 event tag chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../review-queue/[id]/EventTagPicker.tsx | 100 ++++++++++ src/app/admin/review-queue/[id]/page.tsx | 3 + .../admin/review-queue/[id]/events/route.ts | 63 +++++++ src/app/home-page/page.tsx | 3 +- src/components/FeaturedCarousel.tsx | 175 ++++++++++++++++++ src/components/FeaturedCarouselServer.tsx | 101 ++++++++++ 6 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 src/app/admin/review-queue/[id]/EventTagPicker.tsx create mode 100644 src/app/api/admin/review-queue/[id]/events/route.ts create mode 100644 src/components/FeaturedCarousel.tsx create mode 100644 src/components/FeaturedCarouselServer.tsx diff --git a/src/app/admin/review-queue/[id]/EventTagPicker.tsx b/src/app/admin/review-queue/[id]/EventTagPicker.tsx new file mode 100644 index 0000000..b3a7884 --- /dev/null +++ b/src/app/admin/review-queue/[id]/EventTagPicker.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface EventRow { + id: string; + slug: string; + name: string; + start_date: string; + end_date: string; + status: string; +} + +export default function EventTagPicker({ articleId }: { articleId: string }) { + const [events, setEvents] = useState([]); + const [tagged, setTagged] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [err, setErr] = useState(null); + + async function load() { + setLoading(true); + setErr(null); + try { + const r = await fetch(`/api/admin/review-queue/${articleId}/events`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const d = await r.json(); + setEvents(d.events || []); + setTagged(new Set(d.tagged_event_ids || [])); + } catch (e: any) { + setErr(e.message || String(e)); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, [articleId]); + + async function toggle(ev: EventRow) { + const isOn = tagged.has(ev.id); + setBusy(ev.id); + setErr(null); + try { + const r = await fetch(`/api/admin/review-queue/${articleId}/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ event_id: ev.id, action: isOn ? "remove" : "add" }), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + setTagged((s) => { + const n = new Set(s); + if (isOn) n.delete(ev.id); else n.add(ev.id); + return n; + }); + } catch (e: any) { + setErr(e.message || String(e)); + } finally { + setBusy(null); + } + } + + return ( +
+

+ Tag to events +

+ {loading ? ( +

Loading…

+ ) : err ? ( +

{err}

+ ) : ( +
+ {events.map((ev) => { + const on = tagged.has(ev.id); + return ( + + ); + })} +
+ )} +

+ Click a chip to add/remove. Tagged articles appear on + /show-coverage/<event-slug>. +

+
+ ); +} diff --git a/src/app/admin/review-queue/[id]/page.tsx b/src/app/admin/review-queue/[id]/page.tsx index 422184b..062b381 100644 --- a/src/app/admin/review-queue/[id]/page.tsx +++ b/src/app/admin/review-queue/[id]/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { createAdminClient } from "@/lib/supabase/admin"; +import EventTagPicker from './EventTagPicker'; import ReviewActions from "./ReviewActions"; export const dynamic = "force-dynamic"; @@ -56,6 +57,8 @@ export default async function ReviewDetailPage({ params }: { params: Promise<{ i

{art.excerpt}

+ +
diff --git a/src/app/api/admin/review-queue/[id]/events/route.ts b/src/app/api/admin/review-queue/[id]/events/route.ts new file mode 100644 index 0000000..0641ea5 --- /dev/null +++ b/src/app/api/admin/review-queue/[id]/events/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; + +export const runtime = "nodejs"; + +async function requireAdmin() { + const sb = await createClient(); + const { data: { user } } = await sb.auth.getUser(); + if (!user) return { ok: false as const, status: 401 }; + const { data: profile } = await sb + .from("user_profiles") + .select("role, is_active") + .eq("id", user.id) + .maybeSingle(); + if (!profile?.is_active) return { ok: false as const, status: 403 }; + if (!["administrator", "admin", "editor"].includes((profile as any).role)) { + return { ok: false as const, status: 403 }; + } + return { ok: true as const }; +} + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = await requireAdmin(); + if (!guard.ok) return NextResponse.json({ error: "Unauthorized" }, { status: guard.status }); + const { id } = await params; + const admin = createAdminClient(); + const [{ data: events }, { data: tagged }] = await Promise.all([ + admin.from("events").select("id, slug, name, start_date, end_date, status").order("start_date"), + admin.from("article_events").select("event_id").eq("article_id", id).eq("article_table", "ai_rewritten"), + ]); + const taggedIds = new Set((tagged || []).map((r: any) => r.event_id)); + return NextResponse.json({ + events: (events || []) as any[], + tagged_event_ids: [...taggedIds], + }); +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = await requireAdmin(); + if (!guard.ok) return NextResponse.json({ error: "Unauthorized" }, { status: guard.status }); + const { id } = await params; + const { event_id, action } = (await req.json()) as { event_id?: string; action?: string }; + if (!event_id || !["add", "remove"].includes(action || "")) { + return NextResponse.json({ error: "event_id + action(add|remove) required" }, { status: 400 }); + } + const admin = createAdminClient(); + if (action === "add") { + const { error } = await admin + .from("article_events") + .upsert({ article_id: id, article_table: "ai_rewritten", event_id }, { onConflict: "article_id,article_table,event_id" }); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + } else { + const { error } = await admin + .from("article_events") + .delete() + .eq("article_id", id) + .eq("article_table", "ai_rewritten") + .eq("event_id", event_id); + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/home-page/page.tsx b/src/app/home-page/page.tsx index 58e512c..50fc7f9 100644 --- a/src/app/home-page/page.tsx +++ b/src/app/home-page/page.tsx @@ -2,6 +2,7 @@ import React, { Suspense } from "react"; import type { Metadata } from "next"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; +import FeaturedCarouselServer from '@/components/FeaturedCarouselServer'; import TrendsSidebar from '@/components/TrendsSidebar'; import LiveWireTicker from '@/components/LiveWireTicker'; import NewsTicker from "./components/NewsTicker"; @@ -157,7 +158,7 @@ export default function HomePage() { {/* Featured articles bento */} }> - + diff --git a/src/components/FeaturedCarousel.tsx b/src/components/FeaturedCarousel.tsx new file mode 100644 index 0000000..f999943 --- /dev/null +++ b/src/components/FeaturedCarousel.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; + +interface Slide { + id: string; + slug: string; + title: string; + excerpt: string; + image: string | null; + category: string; + author: string; + author_initials: string; + author_beat: string | null; + date: string; + read_min: number; + ago_min: number; + neural_summary: NeuralSummary | null; +} + +interface NeuralSummary { + bullets?: string[]; + confidence?: number; + entities?: string[]; +} + +export default function FeaturedCarousel({ slides }: { slides: Slide[] }) { + const [i, setI] = useState(0); + const [paused, setPaused] = useState(false); + + useEffect(() => { + if (slides.length < 2 || paused) return; + const t = setInterval(() => setI((v) => (v + 1) % slides.length), 4000); + return () => clearInterval(t); + }, [slides.length, paused]); + + if (slides.length === 0) return null; + const s = slides[i]; + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + className="max-w-container mx-auto px-4 my-8" + aria-label="Featured editorial" + > +
+

+ Featured · staff editorial +

+ + auto · 4s + +
+ +
+
+ {s.image ? ( + {s.title} + ) : ( +
+ no image +
+ )} +
+ + [ID {s.id.slice(0, 4).toUpperCase()}] + + + [{s.category.replace(/\s+/g, "-")}] + +
+
+ +
+
+ {s.category} + · + {s.ago_min}min ago + · + {s.read_min}min read +
+ + +

+ {s.title} +

+ +

+ {s.excerpt} +

+ + {s.neural_summary && Array.isArray(s.neural_summary.bullets) && s.neural_summary.bullets.length > 0 && ( +
+ + +
+ + Neural summary + + {typeof s.neural_summary.confidence === "number" && ( + + conf {(s.neural_summary.confidence * 100).toFixed(0)}% + + )} +
+
    + {s.neural_summary.bullets.slice(0, 3).map((b, idx) => ( +
  • + · + {b} +
  • + ))} +
+ {s.neural_summary.entities && s.neural_summary.entities.length > 0 && ( +
+ {s.neural_summary.entities.slice(0, 5).map((e) => ( + + {e} + + ))} +
+ )} +
+ )} + +
+ + {s.author_initials} + +
+
{s.author}
+ {s.author_beat && ( +
{s.author_beat}
+ )} +
+
+ +
+
+ {slides.map((_, n) => ( +
+
+ + {String(i + 1).padStart(2, "0")} / {String(slides.length).padStart(2, "0")} + +
+
+
+
+
+ ); +} diff --git a/src/components/FeaturedCarouselServer.tsx b/src/components/FeaturedCarouselServer.tsx new file mode 100644 index 0000000..3947713 --- /dev/null +++ b/src/components/FeaturedCarouselServer.tsx @@ -0,0 +1,101 @@ +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 ; +}