Phase 6C featured carousel + Phase 2 event tag chips

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>
This commit is contained in:
Claude (Phase B)
2026-05-15 08:20:49 +00:00
parent a785ef428e
commit 4546d5be3b
6 changed files with 444 additions and 1 deletions

View File

@@ -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<EventRow[]>([]);
const [tagged, setTagged] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(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 (
<section className="card rounded border border-[#1f2937] bg-[#0b0f17] p-5">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[#9ca3af] mb-3">
Tag to events
</h2>
{loading ? (
<p className="text-xs text-[#6b7280]">Loading</p>
) : err ? (
<p className="text-sm text-red-400">{err}</p>
) : (
<div className="flex flex-wrap gap-2">
{events.map((ev) => {
const on = tagged.has(ev.id);
return (
<button
key={ev.id}
onClick={() => toggle(ev)}
disabled={busy === ev.id}
className={
"text-xs px-2.5 py-1 rounded-full border transition-colors " +
(on
? "bg-[var(--color-text-info,#60a5fa)]/15 border-[var(--color-text-info,#60a5fa)]/60 text-[var(--color-text-info,#60a5fa)]"
: "border-[#374151] text-[#9ca3af] hover:border-[var(--color-text-info,#60a5fa)] hover:text-[var(--color-text-info,#60a5fa)]")
}
title={`${ev.start_date} ${ev.end_date} (${ev.status})`}
>
{on ? "✓ " : "+ "}{ev.name}
</button>
);
})}
</div>
)}
<p className="mt-3 text-[11px] font-mono text-[#6b7280]">
Click a chip to add/remove. Tagged articles appear on
/show-coverage/&lt;event-slug&gt;.
</p>
</section>
);
}

View File

@@ -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
<p className="mt-2 text-[#9ca3af]">{art.excerpt}</p>
</header>
<EventTagPicker articleId={art.id} />
<div className="my-4" />
<ReviewActions id={art.id} currentStatus={art.status} />
<section className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-8">

View File

@@ -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 });
}

View File

@@ -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 */}
<ScrollRevealSection className="section-enter section-enter-3">
<Suspense fallback={<BentoSkeleton />}>
<FeaturedBento />
<FeaturedCarouselServer />
</Suspense>
</ScrollRevealSection>