'use client'; /** * Topic review workspace — the "live-ranked list": topics ranked by ai_rank, * triaged fast without a drawer (good/bad/edit verdict, status move, team note). * Rejected sink to the bottom (kept, not deleted — a persistent avoid-list); * written drop into a Done section. Click a title for the app-provided full * editor (renderDetail). Generic — the controlled-vocab markets + the detail * drawer are injected by the consumer. */ import { useMemo, useState, type ReactNode } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, ThumbsUp, ThumbsDown, PencilLine, ExternalLink, AlertTriangle, ShieldCheck } from 'lucide-react'; import { Button } from '../ui/button'; import { Textarea } from '../ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { notify } from '../toast/use-notify'; import type { CollectionClient, EntityRecord, EntityTypeDef } from './types'; import { readData, toCamelKey } from './data'; const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const; const VERDICTS = [ { value: 'good', label: 'Good', icon: ThumbsUp, on: 'bg-emerald-600 text-white border-emerald-600' }, { value: 'bad', label: 'Bad', icon: ThumbsDown, on: 'bg-rose-600 text-white border-rose-600' }, { value: 'edit', label: 'Edit', icon: PencilLine, on: 'bg-amber-500 text-white border-amber-500' }, ] as const; function field(r: EntityRecord, name: string): string { const v = readData(r.data, name); return v == null ? '' : String(v); } function rankOf(r: EntityRecord): number { const n = Number(readData(r.data, 'ai_rank')); return Number.isFinite(n) ? n : 0; } function hostOf(url: string): string { try { return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname.replace(/^www\./, ''); } catch { return ''; } } export interface TopicReviewWorkspaceProps { client: CollectionClient; /** Controlled-vocab markets locking the market dropdown (kills value drift). */ markets?: string[]; /** Render the app's full-record editor when a title is clicked (optional). */ renderDetail?: (record: EntityRecord, type: EntityTypeDef, onClose: () => void, onSaved: () => void) => ReactNode; } export function TopicReviewWorkspace({ client, markets = [], renderDetail }: TopicReviewWorkspaceProps) { const qc = useQueryClient(); const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() }); const topicType = typesQ.data?.results.find((t) => t.key === 'topic'); const recordsQ = useQuery({ queryKey: ['entities', 'topic', 'all'], queryFn: () => client.listAllEntities('topic'), enabled: !!topicType, }); const records = recordsQ.data ?? []; const sourcesQ = useQuery({ queryKey: ['entities', 'source', 'all'], queryFn: () => client.listAllEntities('source'), enabled: !!topicType, }); const tierByDomain = useMemo(() => { const m = new Map(); for (const s of sourcesQ.data ?? []) { const d = String(readData(s.data, 'domain') ?? '').replace(/^www\./, '').toLowerCase(); const t = Number(readData(s.data, 'tier')); if (d) m.set(d, Number.isFinite(t) ? t : 0); } return m; }, [sourcesQ.data]); const [selected, setSelected] = useState(null); const [showRejected, setShowRejected] = useState(false); const [showWritten, setShowWritten] = useState(false); const { active, written, rejected } = useMemo(() => { const a: EntityRecord[] = []; const w: EntityRecord[] = []; const rej: EntityRecord[] = []; for (const r of records) { const s = field(r, 'status'); if (s === 'rejected') rej.push(r); else if (s === 'written') w.push(r); else a.push(r); } const byRank = (x: EntityRecord, y: EntityRecord) => rankOf(y) - rankOf(x); return { active: a.sort(byRank), written: w.sort(byRank), rejected: rej.sort(byRank) }; }, [records]); async function patch(record: EntityRecord, changes: Record) { const data: Record = { ...record.data }; for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v; try { await client.updateEntity(record.id, { data }); await qc.invalidateQueries({ queryKey: ['entities', 'topic'] }); } catch (err) { notify.error(err instanceof Error ? err.message : 'Could not save.'); } } if (typesQ.isLoading || recordsQ.isLoading) { return

Loading topics…

; } if (!topicType) { return

No “topic” type is defined for this app.

; } return (

Topic review

Ranked by AI. Rate each topic, jot a note, and move it along — rejected sink to the bottom.

{active.length} to review · {written.length} written · {rejected.length} rejected
{active.length === 0 ? (

Nothing to review right now.

) : (
    {active.map((r, i) => ( setSelected(r)} onPatch={patch} tierByDomain={tierByDomain} /> ))}
)} {written.length > 0 && (
setShowWritten((v) => !v)}>
    {written.map((r) => ( setSelected(r)} onPatch={patch} tierByDomain={tierByDomain} muted /> ))}
)} {rejected.length > 0 && (
setShowRejected((v) => !v)}>
    {rejected.map((r) => ( setSelected(r)} onPatch={patch} tierByDomain={tierByDomain} muted /> ))}
)} {selected && renderDetail?.(selected, topicType, () => setSelected(null), () => recordsQ.refetch())}
); } function Section({ label, open, onToggle, children }: { label: string; open: boolean; onToggle: () => void; children: ReactNode }) { return (
{open &&
{children}
}
); } function TopicRow({ record, rank, markets, onOpen, onPatch, tierByDomain, muted, }: { record: EntityRecord; rank?: number; markets: string[]; onOpen: () => void; onPatch: (r: EntityRecord, changes: Record) => Promise; tierByDomain: Map; muted?: boolean; }) { const title = field(record, 'title') || record.name || `#${record.id}`; const angle = field(record, 'angle'); const market = field(record, 'market'); const contentType = field(record, 'content_type'); const status = field(record, 'status') || 'suggested'; const verdict = field(record, 'team_verdict'); const [noteOpen, setNoteOpen] = useState(false); const [note, setNote] = useState(field(record, 'team_notes')); const [savingNote, setSavingNote] = useState(false); async function saveNote() { setSavingNote(true); await onPatch(record, { team_notes: note }); setSavingNote(false); setNoteOpen(false); } return (
  • {rank != null && ( {rank} )}
    {angle &&

    {angle}

    }
    {contentType && {contentType.replace(/_/g, ' ')}}
    {VERDICTS.map((v) => { const active = verdict === v.value; const Icon = v.icon; return ( ); })}
    {noteOpen && (