'use client'; /** * Draft review workspace — "compare the candidates, pick one, review it, mark it * ready to post". A writer emits N candidate drafts per approved topic; the team * compares a story's candidates, picks the one to publish, reads the full draft * (blog + LinkedIn + SEO) with the isolated Content-Judge verdict + auto-checks, * and marks the chosen final ready to post (posting stays a manual step). * Client-injected + generic — no OGMC specifics. */ import { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ShieldCheck, AlertTriangle, ExternalLink, CheckCircle2, Circle } from 'lucide-react'; import { Button } from '../ui/button'; import { Badge } from '../ui/badge'; import { notify } from '../toast/use-notify'; import { MarkdownRenderer } from '../blog/MarkdownRenderer'; import type { CollectionClient, EntityRecord } from './types'; import { readData, toCamelKey } from './data'; import { groupDrafts, parseSources, candidateTitle, candidateIndex, isChosen, gradeWords, wordTargets, sourceOverlapCheck, pickChanges, type DraftGroup, type DraftSource, type CheckStatus, } from './drafts'; function field(r: EntityRecord, name: string): string { const v = readData(r.data, name); return v == null ? '' : String(v); } function obj(v: unknown): Record { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; } function get(o: Record, camel: string, snake: string): unknown { return o[camel] ?? o[snake]; } function hostOf(url: string): string { try { return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname.replace(/^www\./, ''); } catch { return ''; } } export interface DraftReviewWorkspaceProps { client: CollectionClient; } export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) { const qc = useQueryClient(); const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() }); const draftType = typesQ.data?.results.find((t) => t.key === 'draft'); const recordsQ = useQuery({ queryKey: ['entities', 'draft', 'all'], queryFn: () => client.listAllEntities('draft'), enabled: !!draftType, }); const records = recordsQ.data ?? []; const sourcesQ = useQuery({ queryKey: ['entities', 'source', 'all'], queryFn: () => client.listAllEntities('source'), enabled: !!draftType, }); 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 groups = useMemo(() => groupDrafts(records), [records]); const [openId, setOpenId] = useState(null); const [busyKey, setBusyKey] = useState(null); async function writeEntity(record: EntityRecord, changes: Record) { const data: Record = { ...record.data }; for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v; return client.updateEntity(record.id, { data }); } async function pick(group: DraftGroup, cand: EntityRecord) { setBusyKey(group.key); try { await Promise.all(group.candidates.map((c) => writeEntity(c, pickChanges(c.id, cand.id)))); await qc.invalidateQueries({ queryKey: ['entities', 'draft'] }); } catch (err) { notify.error(err instanceof Error ? err.message : 'Could not save the pick.'); } finally { setBusyKey(null); } } async function markReady(record: EntityRecord, reason?: string) { setBusyKey(String(record.id)); try { await writeEntity(record, { status: 'ready', chosen: true, // A source-overlap fail doesn't hard-block the accept — the reviewer can // override with a reason (768w.18.16.2). Record it so the override is auditable. ...(reason ? { ready_override_reason: reason } : {}), }); await qc.invalidateQueries({ queryKey: ['entities', 'draft'] }); } catch (err) { notify.error(err instanceof Error ? err.message : 'Could not mark ready.'); } finally { setBusyKey(null); } } if (typesQ.isLoading || recordsQ.isLoading) { return

Loading drafts…

; } if (!draftType) { return

No “draft” type is defined for this app.

; } const pickedCount = groups.filter((g) => g.chosen).length; const readyCount = groups.filter((g) => g.ready).length; return (

Draft review

Each story has a few AI-written candidates. Compare them, pick the one to publish, and check it against the judge before marking it ready.

{groups.length} stories · {pickedCount} picked · {readyCount} ready to post
{groups.length === 0 ? (

No drafts yet. Move a topic to “ready” and the writer will generate candidates here.

) : (
    {groups.map((g) => ( setOpenId((cur) => (cur === id ? null : id))} onPick={pick} onMarkReady={markReady} busy={busyKey === g.key} busyId={busyKey} /> ))}
)}
); } function StoryGroup({ group, tierByDomain, openId, onToggleOpen, onPick, onMarkReady, busy, busyId, }: { group: DraftGroup; tierByDomain: Map; openId: string | null; onToggleOpen: (id: string) => void; onPick: (g: DraftGroup, c: EntityRecord) => Promise; onMarkReady: (c: EntityRecord, reason?: string) => Promise; busy: boolean; busyId: string | null; }) { const headSources = parseSources(readData(group.candidates[0].data, 'sources')); return (
  • {group.label}

    {group.contentType && ( {group.contentType.replace(/_/g, ' ')} )} {group.candidates.length} candidate{group.candidates.length === 1 ? '' : 's'} {group.ready && ready to post}
    {group.candidates.map((c) => ( onToggleOpen(String(c.id))} onPick={() => onPick(group, c)} picking={busy} /> ))}
    {group.candidates .filter((c) => openId === String(c.id)) .map((c) => ( onMarkReady(c, reason)} marking={busyId === String(c.id)} /> ))}
  • ); } function CandidateCard({ record, contentType, open, onToggle, onPick, picking, }: { record: EntityRecord; contentType: string; open: boolean; onToggle: () => void; onPick: () => void; picking: boolean; }) { const chosen = isChosen(record); const status = field(record, 'status'); const preview = field(record, 'blog').slice(0, 150); const checks = obj(readData(record.data, 'auto_checks')); const blogWords = Number(get(checks, 'blogWords', 'blog_words')) || 0; const target = wordTargets(contentType).blog; return (
    Candidate {candidateIndex(record) || '—'} {status === 'ready' ? ( ready ) : chosen ? ( picked ) : null}

    {candidateTitle(record)}

    {preview}…

    ); } function ContentReview({ record, contentType, onMarkReady, marking, }: { record: EntityRecord; contentType: string; onMarkReady: (reason?: string) => void; marking: boolean; }) { const blog = field(record, 'blog'); const linkedin = field(record, 'linkedin'); const seo = obj(readData(record.data, 'seo')); const judge = obj(readData(record.data, 'judge_verdict')); const checks = obj(readData(record.data, 'auto_checks')); const ready = field(record, 'status') === 'ready'; // Verbatim-overlap guardrail (768w.18.16.2): recompute the source-overlap check // client-side against the cited article text on the record. Only surfaces when // the draft actually carries source_texts. A fail gates the accept below. const overlap = sourceOverlapCheck(record); const overlapFail = overlap?.status === 'fail'; const [override, setOverride] = useState(''); const blocked = overlapFail && !ready && override.trim().length < 4; const tags = ((): string[] => { const t = seo.tags; if (Array.isArray(t)) return t.map(String); if (typeof t === 'string') return t.split(',').map((s) => s.trim()).filter(Boolean); return []; })(); const targets = wordTargets(contentType); const blogWords = Number(get(checks, 'blogWords', 'blog_words')) || 0; const liWords = Number(get(checks, 'linkedinWords', 'linkedin_words')) || 0; const hype = get(checks, 'hypeFlags', 'hype_flags'); const approvedSources = get(checks, 'approvedSources', 'approved_sources'); const issues = Array.isArray(judge.issues) ? (judge.issues as Record[]) : []; const verdict = String(judge.verdict ?? ''); const summary = String(judge.summary ?? ''); const scores = obj(judge.scores); return (
    Blog post {blog ? (
    ) : ( No blog body. )}
    {linkedin && (
    LinkedIn post

    {linkedin}

    )} {(seo.metaDescription || seo.meta_description || seo.primaryKeyword || seo.primary_keyword || tags.length > 0) && (
    SEO

    Meta: {String(get(seo, 'metaDescription', 'meta_description') ?? '—')}

    Keyword: {String(get(seo, 'primaryKeyword', 'primary_keyword') ?? '—')}

    {tags.length > 0 && (
    {tags.map((t) => ( {t} ))}
    )}
    )}
    Content judge
    {summary && {summary}}
    {Object.keys(scores).length > 0 && (
    {Object.entries(scores).map(([k, v]) => ( {k.replace(/_/g, ' ')} {String(v)} ))}
    )} {issues.length === 0 ? (

    No issues flagged.

    ) : (
      {issues.map((it, i) => (
    • {it.severity ? : null} {it.guardrail ? {String(it.guardrail)} : null}
      {it.problem ?

      {String(it.problem)}

      : null} {it.fix ?

      Fix: {String(it.fix)}

      : null}
    • ))}
    )}
    Auto-checks
    {Array.isArray(hype) && ( {hype.length === 0 ? 'no hype' : `${hype.length} hype flag${hype.length === 1 ? '' : 's'}`} )} {approvedSources != null && ( sources {approvedSources ? 'approved' : 'unverified'} )} {overlap && ( {overlap.status === 'pass' ? 'original wording' : `source overlap: ${overlap.detail}`} )}
    {overlapFail && !ready && (

    This draft copies too much from its source ({overlap?.detail}).

    Paraphrase the flagged run, or give a reason to publish it anyway.