HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ActionButton, AdminFilters, AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';4import { EntityBadge, TierBadge } from '@/components/ui/badges';5import { LiveAgo } from '@/components/ui/live';6import { Pagination, withParams } from '@/components/ui/pagination';7import { EmptyState, Unavailable } from '@/components/ui/unavailable';8import { keepConflictSideAction, reviewAction } from '@/lib/admin/actions';9import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';10import type { ReviewItem } from '@/lib/admin/types';11import { fmtDateTime, fmtInt, fmtValue, num } from '@/lib/format';12import { propertyLabel, routes } from '@/lib/site';1314export const metadata: Metadata = { title: 'Review queue', robots: { index: false, follow: false } };15export const dynamic = 'force-dynamic';1617const LIMIT = 25;18const STATUSES = ['pending', 'approved', 'rejected', 'edited', 'all'];19const KINDS = ['conflict', 'merge_candidate', 'parser_breakage', 'blocked_source'];2021function host(url: unknown): string {22 if (typeof url !== 'string') return '—';23 try {24 return new URL(url).hostname.replace(/^www\./, '');25 } catch {26 return url;27 }28}2930/** Conflict payload `{ property, current, claimed, current_source, claimed_source, tier }` rendered as two columns, each with a "Keep" button. */31function ConflictView({ item, ret }: { item: ReviewItem; ret: string }) {32 const p = item.payload ?? {};33 const property = typeof p.property === 'string' ? p.property : null;34 const entity = item.entities?.[0];35 const sides = [36 { key: 'current', title: 'Current value', value: p.current, source: p.current_source, tier: p.current_tier ?? null },37 { key: 'claimed', title: 'Claimed by new source', value: p.claimed, source: p.claimed_source, tier: p.tier ?? p.claimed_tier ?? null },38 ];39 return (40 <div className="mt-2 grid gap-3 md:grid-cols-2">41 {sides.map((s) => (42 <div key={s.key} className="border border-rule p-3">43 <p className="eyebrow">{s.title}</p>44 <p className="tnum mt-1 break-words text-sm font-medium text-ink">{fmtValue(s.value, property ?? undefined)}</p>45 <p className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3">46 {typeof s.source === 'string' ? (47 <a href={s.source} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent" title={s.source}>48 {host(s.source)}49 </a>50 ) : (51 <span>source unknown</span>52 )}53 {num(s.tier) !== null && <TierBadge tier={num(s.tier)} />}54 </p>55 {item.status === 'pending' && property && entity && (56 <form action={keepConflictSideAction} className="mt-2">57 <input type="hidden" name="id" value={item.id} />58 <input type="hidden" name="slug" value={entity.slug} />59 <input type="hidden" name="property" value={property} />60 <input type="hidden" name="value" value={JSON.stringify(s.value ?? null)} />61 <input type="hidden" name="source_url" value={typeof s.source === 'string' ? s.source : ''} />62 <input type="hidden" name="return" value={ret} />63 <ActionButton tone="positive" title="Promote this claim to current and supersede the other">Keep this</ActionButton>64 </form>65 )}66 </div>67 ))}68 </div>69 );70}7172export default async function AdminReviewPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {73 await requireAdmin();74 const sp = await searchParams;75 const current: Record<string, string | undefined> = { status: sp.status ?? 'pending' };76 if (sp.kind) current.kind = sp.kind;77 if (sp.offset) current.offset = sp.offset;78 const offset = Math.max(0, Number(current.offset) || 0);79 const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/review', current, patch);80 const ret = href({});81 const res = await load(adminApi.review({ status: current.status, kind: current.kind, limit: LIMIT, offset }));82 const byKind = res.ok ? (res.data.by_kind ?? []).filter((k) => k.status === (current.status === 'all' ? k.status : current.status)) : [];83 return (84 <>85 <AdminTitle title="Review queue" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Conflicts keep both claims until an operator picks one; merges move aliases, identifiers, claims, relations and events into the target." />86 <Notice notice={sp.notice} level={sp.level} />87 {byKind.length > 0 && (88 <ul className="mb-4 flex flex-wrap gap-1.5 text-xs">89 {byKind.map((k) => (90 <li key={`${k.kind}-${k.status}`}>91 <Link href={href({ kind: current.kind === k.kind ? undefined : k.kind, offset: undefined })} className={`inline-flex h-7 items-center gap-1.5 border px-2 ${current.kind === k.kind ? 'border-accent text-accent' : 'border-rule text-ink-2 hover:border-rule-strong'}`}>92 <span className="mono">{k.kind}</span> <span className="tnum">{fmtInt(k.n)}</span> {current.status === 'all' && <StatusChip value={k.status} />}93 </Link>94 </li>95 ))}96 </ul>97 )}98 <AdminFilters99 action="/admin/review"100 className="mb-4"101 fields={[102 { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: STATUSES.map((s) => ({ value: s, label: s })) },103 { kind: 'select', name: 'kind', label: 'Kind', value: current.kind, options: KINDS.map((k) => ({ value: k, label: k })) },104 ]}105 />106 {!res.ok ? (107 <Unavailable what="Review queue" reason={res.error} />108 ) : res.data.items.length === 0 ? (109 <EmptyState title="Nothing to review" />110 ) : (111 <>112 <ul className="divide-y divide-rule border-y border-rule">113 {res.data.items.map((item) => {114 const p = item.payload ?? {};115 const property = typeof p.property === 'string' ? p.property : null;116 return (117 <li key={item.id} className="py-3">118 <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">119 <div className="min-w-0 flex-1">120 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">121 <KindChip value={item.kind} />122 <StatusChip value={item.status} />123 {property && <span className="text-xs text-ink-2">{propertyLabel(property)}</span>}124 <span className="text-xs text-ink-3" title={fmtDateTime(item.created_at)}>125 <LiveAgo at={item.created_at} />126 </span>127 <Mono>{item.id}</Mono>128 </div>129 {item.reason && <p className="mt-1 text-sm text-ink"><Trunc text={item.reason} max={200} /></p>}130 {item.entities && item.entities.length > 0 && (131 <ul className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-sm">132 {item.entities.map((e) => (133 <li key={e.id} className="flex items-center gap-1.5">134 <EntityBadge type={e.entity_type} small />135 <Link href={routes.entity(e)} className="text-ink hover:text-accent hover:underline">{e.name}</Link>136 <Mono title={e.id}>{e.slug}</Mono>137 {e.status !== 'active' && <StatusChip value={e.status} />}138 </li>139 ))}140 </ul>141 )}142 {item.kind === 'conflict' && <ConflictView item={item} ret={ret} />}143 {item.kind === 'merge_candidate' && item.entities && item.entities.length > 1 && item.status === 'pending' && (144 <p className="mt-1.5 text-xs text-ink-3">145 Approve merges <Mono>{item.entity_ids[0]}</Mono> into <Mono>{item.entity_ids[1]}</Mono> (first two ids); pick directions explicitly under <Link href={`/admin/entities/duplicates?type=${encodeURIComponent(item.entities[0]?.entity_type ?? '')}`} className="link">Duplicates</Link> when the order is wrong.146 </p>147 )}148 <details className="mt-2">149 <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Payload{item.resolution ? ' · resolution' : ''}</summary>150 <div className="mt-1 grid gap-2 md:grid-cols-2">151 <JsonPre value={item.payload} maxHeight="16rem" />152 {item.resolution && <JsonPre value={item.resolution} maxHeight="16rem" />}153 </div>154 </details>155 </div>156 {item.status === 'pending' && (157 <div className="flex shrink-0 items-center gap-1.5">158 <form action={reviewAction}>159 <input type="hidden" name="id" value={item.id} />160 <input type="hidden" name="action" value="approve" />161 <input type="hidden" name="return" value={ret} />162 <ActionButton tone="positive" title={item.kind === 'conflict' ? 'Mark approved without promoting a claim (use "Keep this" to pick a side)' : 'Approve'}>Approve</ActionButton>163 </form>164 <form action={reviewAction}>165 <input type="hidden" name="id" value={item.id} />166 <input type="hidden" name="action" value="reject" />167 <input type="hidden" name="return" value={ret} />168 <ActionButton tone="danger">Reject</ActionButton>169 </form>170 </div>171 )}172 </div>173 </li>174 );175 })}176 </ul>177 <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />178 </>179 )}180 </>181 );182}183