SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
10.2 KB · 183 lines tsx
Raw Blame History
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