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">
|
||||
|
||||
63
src/app/api/admin/review-queue/[id]/events/route.ts
Normal file
63
src/app/api/admin/review-queue/[id]/events/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
175
src/components/FeaturedCarousel.tsx
Normal file
175
src/components/FeaturedCarousel.tsx
Normal file
@@ -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 (
|
||||
<section
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
className="max-w-container mx-auto px-4 my-8"
|
||||
aria-label="Featured editorial"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280]">
|
||||
Featured · staff editorial
|
||||
</h2>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
|
||||
<span className="bb-pulse-dot" /> auto · 4s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 rounded border border-[#252525] bg-[#0b0f17] overflow-hidden">
|
||||
<div className="relative aspect-[16/10] bg-[#111]">
|
||||
{s.image ? (
|
||||
<img src={s.image} alt={s.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[#6b7280] font-mono text-xs">
|
||||
no image
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 flex gap-1 text-[10px] font-mono">
|
||||
<span className="bg-black/60 text-[var(--color-text-info,#60a5fa)] px-1.5 py-0.5 rounded">
|
||||
[ID {s.id.slice(0, 4).toUpperCase()}]
|
||||
</span>
|
||||
<span className="bg-black/60 text-white px-1.5 py-0.5 rounded uppercase">
|
||||
[{s.category.replace(/\s+/g, "-")}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-mono text-[#6b7280] mb-2">
|
||||
<span>{s.category}</span>
|
||||
<span>·</span>
|
||||
<span>{s.ago_min}min ago</span>
|
||||
<span>·</span>
|
||||
<span>{s.read_min}min read</span>
|
||||
</div>
|
||||
|
||||
<Link href={`/news/${s.slug}`} className="block group">
|
||||
<h3 className="font-serif text-2xl leading-snug text-[#e5e7eb] group-hover:text-[var(--color-text-info,#60a5fa)]">
|
||||
{s.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="font-serif text-[15px] text-[#b0b0b0] mt-3 line-clamp-3">
|
||||
{s.excerpt}
|
||||
</p>
|
||||
|
||||
{s.neural_summary && Array.isArray(s.neural_summary.bullets) && s.neural_summary.bullets.length > 0 && (
|
||||
<div className="relative mt-4 p-3 border border-[#252525] rounded">
|
||||
<span aria-hidden className="absolute top-1 left-1 w-2.5 h-2.5 border-t border-l border-[var(--color-text-info,#60a5fa)]" />
|
||||
<span aria-hidden className="absolute bottom-1 right-1 w-2.5 h-2.5 border-b border-r border-[var(--color-text-info,#60a5fa)]" />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
|
||||
Neural summary
|
||||
</span>
|
||||
{typeof s.neural_summary.confidence === "number" && (
|
||||
<span className="text-[10px] font-mono text-[#6b7280]">
|
||||
conf {(s.neural_summary.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-1 text-xs text-[#d1d5db]">
|
||||
{s.neural_summary.bullets.slice(0, 3).map((b, idx) => (
|
||||
<li key={idx} className="flex gap-2">
|
||||
<span className="text-[var(--color-text-info,#60a5fa)]">·</span>
|
||||
<span>{b}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{s.neural_summary.entities && s.neural_summary.entities.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{s.neural_summary.entities.slice(0, 5).map((e) => (
|
||||
<span key={e} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-[#1a1f2e] text-[var(--color-text-info,#60a5fa)]">
|
||||
{e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-[#252525] flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-[#1a1f2e] text-[var(--color-text-info,#60a5fa)] text-xs font-semibold">
|
||||
{s.author_initials}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm text-[#e5e7eb]">{s.author}</div>
|
||||
{s.author_beat && (
|
||||
<div className="text-[10px] font-mono text-[#6b7280] uppercase tracking-wider">{s.author_beat}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex gap-1.5">
|
||||
{slides.map((_, n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setI(n)}
|
||||
aria-label={`Slide ${n + 1}`}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
n === i ? "bg-[var(--color-text-info,#60a5fa)]" : "bg-[#374151]"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs font-mono text-[#6b7280]">
|
||||
<button
|
||||
onClick={() => setI((v) => (v - 1 + slides.length) % slides.length)}
|
||||
aria-label="Previous"
|
||||
className="hover:text-[var(--color-text-info,#60a5fa)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span>{String(i + 1).padStart(2, "0")} / {String(slides.length).padStart(2, "0")}</span>
|
||||
<button
|
||||
onClick={() => setI((v) => (v + 1) % slides.length)}
|
||||
aria-label="Next"
|
||||
className="hover:text-[var(--color-text-info,#60a5fa)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
101
src/components/FeaturedCarouselServer.tsx
Normal file
101
src/components/FeaturedCarouselServer.tsx
Normal file
@@ -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 <FeaturedCarousel slides={top5} />;
|
||||
}
|
||||
Reference in New Issue
Block a user