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:
100
src/app/admin/review-queue/[id]/EventTagPicker.tsx
Normal file
100
src/app/admin/review-queue/[id]/EventTagPicker.tsx
Normal 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/<event-slug>.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user